diff --git a/.coveragerc b/.coveragerc index 2b5f328466c..967c560198c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -47,6 +47,7 @@ omit = homeassistant/components/august/* homeassistant/components/automatic/device_tracker.py homeassistant/components/avion/light.py + homeassistant/components/azure_event_hub/* homeassistant/components/baidu/tts.py homeassistant/components/bbb_gpio/* homeassistant/components/bbox/device_tracker.py @@ -171,6 +172,7 @@ omit = homeassistant/components/esphome/camera.py homeassistant/components/esphome/climate.py homeassistant/components/esphome/cover.py + homeassistant/components/esphome/entry_data.py homeassistant/components/esphome/fan.py homeassistant/components/esphome/light.py homeassistant/components/esphome/sensor.py @@ -250,7 +252,6 @@ omit = homeassistant/components/hitron_coda/device_tracker.py homeassistant/components/hive/* homeassistant/components/hlk_sw16/* - homeassistant/components/homekit_controller/* homeassistant/components/homematic/* homeassistant/components/homematic/climate.py homeassistant/components/homematic/cover.py @@ -344,6 +345,7 @@ omit = homeassistant/components/mastodon/notify.py homeassistant/components/matrix/* homeassistant/components/maxcube/* + homeassistant/components/mcp23017/* homeassistant/components/media_extractor/* homeassistant/components/mediaroom/media_player.py homeassistant/components/message_bird/notify.py @@ -487,6 +489,9 @@ omit = homeassistant/components/reddit/* homeassistant/components/rejseplanen/sensor.py homeassistant/components/remember_the_milk/__init__.py + homeassistant/components/repetier/__init__.py + homeassistant/components/repetier/sensor.py + homeassistant/components/remote_rpi_gpio/* homeassistant/components/rest/binary_sensor.py homeassistant/components/rest/notify.py homeassistant/components/rest/switch.py @@ -539,12 +544,14 @@ omit = homeassistant/components/slack/notify.py homeassistant/components/sma/sensor.py homeassistant/components/smappee/* + homeassistant/components/smarthab/* homeassistant/components/smtp/notify.py homeassistant/components/snapcast/media_player.py homeassistant/components/snmp/* homeassistant/components/sochain/sensor.py homeassistant/components/socialblade/sensor.py homeassistant/components/solaredge/sensor.py + homeassistant/components/solax/sensor.py homeassistant/components/somfy_mylink/* homeassistant/components/sonarr/sensor.py homeassistant/components/songpal/media_player.py @@ -651,6 +658,7 @@ omit = homeassistant/components/waqi/sensor.py homeassistant/components/waterfurnace/* homeassistant/components/watson_iot/* + homeassistant/components/watson_tts/tts.py homeassistant/components/waze_travel_time/sensor.py homeassistant/components/webostv/* homeassistant/components/wemo/* diff --git a/CODEOWNERS b/CODEOWNERS index 90fb72378bc..59bd8c31af1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -32,6 +32,7 @@ homeassistant/components/automatic/* @armills homeassistant/components/automation/* @home-assistant/core homeassistant/components/aws/* @awarecan @robbiet480 homeassistant/components/axis/* @kane610 +homeassistant/components/azure_event_hub/* @eavanvalkenburg homeassistant/components/bitcoin/* @fabaff homeassistant/components/bizkaibus/* @UgaitzEtxebarria homeassistant/components/blink/* @fronzbot @@ -83,7 +84,7 @@ homeassistant/components/flock/* @fabaff homeassistant/components/flunearyou/* @bachya homeassistant/components/foursquare/* @robbiet480 homeassistant/components/freebox/* @snoof85 -homeassistant/components/frontend/* @home-assistant/core +homeassistant/components/frontend/* @home-assistant/frontend homeassistant/components/gearbest/* @HerrHofrat homeassistant/components/geniushub/* @zxdavb homeassistant/components/gitter/* @fabaff @@ -131,6 +132,7 @@ homeassistant/components/kodi/* @armills homeassistant/components/konnected/* @heythisisnate homeassistant/components/lametric/* @robbiet480 homeassistant/components/launch_library/* @ludeeus +homeassistant/components/lcn/* @alengwenus homeassistant/components/lifx/* @amelchio homeassistant/components/lifx_cloud/* @amelchio homeassistant/components/lifx_legacy/* @amelchio @@ -138,11 +140,12 @@ homeassistant/components/linux_battery/* @fabaff homeassistant/components/liveboxplaytv/* @pschmitt homeassistant/components/logger/* @home-assistant/core homeassistant/components/logi_circle/* @evanjd -homeassistant/components/lovelace/* @home-assistant/core +homeassistant/components/lovelace/* @home-assistant/frontend homeassistant/components/luci/* @fbradyirl homeassistant/components/luftdaten/* @fabaff homeassistant/components/mastodon/* @fabaff homeassistant/components/matrix/* @tinloaf +homeassistant/components/mcp23017/* @jardiamj homeassistant/components/mediaroom/* @dgomes homeassistant/components/melissa/* @kennedyshead homeassistant/components/met/* @danielhiversen @@ -173,8 +176,8 @@ homeassistant/components/openuv/* @bachya homeassistant/components/openweathermap/* @fabaff homeassistant/components/orangepi_gpio/* @pascallj homeassistant/components/owlet/* @oblogic7 -homeassistant/components/panel_custom/* @home-assistant/core -homeassistant/components/panel_iframe/* @home-assistant/core +homeassistant/components/panel_custom/* @home-assistant/frontend +homeassistant/components/panel_iframe/* @home-assistant/frontend homeassistant/components/persistent_notification/* @home-assistant/core homeassistant/components/philips_js/* @elupus homeassistant/components/pi_hole/* @fabaff @@ -190,6 +193,7 @@ homeassistant/components/qwikswitch/* @kellerza homeassistant/components/raincloud/* @vanstinator homeassistant/components/rainmachine/* @bachya homeassistant/components/random/* @fabaff +homeassistant/components/repetier/* @MTrab homeassistant/components/rfxtrx/* @danielhiversen homeassistant/components/rmvtransport/* @cgtobi homeassistant/components/roomba/* @pschmitt @@ -205,15 +209,17 @@ homeassistant/components/shiftr/* @fabaff homeassistant/components/shodan/* @fabaff homeassistant/components/simplisafe/* @bachya homeassistant/components/sma/* @kellerza +homeassistant/components/smarthab/* @outadoc homeassistant/components/smartthings/* @andrewsayre homeassistant/components/smtp/* @fabaff +homeassistant/components/solax/* @squishykid homeassistant/components/sonos/* @amelchio homeassistant/components/spaceapi/* @fabaff homeassistant/components/spider/* @peternijssen homeassistant/components/sql/* @dgomes homeassistant/components/statistics/* @fabaff homeassistant/components/stiebel_eltron/* @fucm -homeassistant/components/sun/* @home-assistant/core +homeassistant/components/sun/* @Swamp-Ig homeassistant/components/supla/* @mwegrzynek homeassistant/components/swiss_hydrological_data/* @fabaff homeassistant/components/swiss_public_transport/* @fabaff @@ -253,6 +259,7 @@ homeassistant/components/velux/* @Julius2342 homeassistant/components/version/* @fabaff homeassistant/components/vizio/* @raman325 homeassistant/components/waqi/* @andrey-git +homeassistant/components/watson_tts/* @rutkai homeassistant/components/weather/* @fabaff homeassistant/components/weblink/* @home-assistant/core homeassistant/components/websocket_api/* @home-assistant/core @@ -261,14 +268,14 @@ homeassistant/components/worldclock/* @fabaff homeassistant/components/xfinity/* @cisasteelersfan homeassistant/components/xiaomi_aqara/* @danielhiversen @syssi homeassistant/components/xiaomi_miio/* @rytilahti @syssi -homeassistant/components/xiaomi_tv/* @fattdev +homeassistant/components/xiaomi_tv/* @simse homeassistant/components/xmpp/* @fabaff @flowolf homeassistant/components/yamaha_musiccast/* @jalmeroth homeassistant/components/yeelight/* @rytilahti @zewelor homeassistant/components/yeelightsunflower/* @lindsaymarkward homeassistant/components/yessssms/* @flowolf homeassistant/components/yi/* @bachya -homeassistant/components/zeroconf/* @robbiet480 +homeassistant/components/zeroconf/* @robbiet480 @Kane610 homeassistant/components/zha/* @dmulcahey @adminiuga homeassistant/components/zone/* @home-assistant/core homeassistant/components/zoneminder/* @rohankapoorcom diff --git a/README.rst b/README.rst index 941a463fb37..08f20778d70 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -Home Assistant |Build Status| |CI Status| |Coverage Status| |Chat Status| +Home Assistant |Chat Status| ================================================================================= Home Assistant is a home automation platform running on Python 3. It is able to track and control all devices at home and offer a platform for automating control. @@ -27,12 +27,6 @@ components `__ of our website for further help and information. -.. |Build Status| image:: https://travis-ci.org/home-assistant/home-assistant.svg?branch=dev - :target: https://travis-ci.org/home-assistant/home-assistant -.. |CI Status| image:: https://circleci.com/gh/home-assistant/home-assistant.svg?style=shield - :target: https://circleci.com/gh/home-assistant/home-assistant -.. |Coverage Status| image:: https://img.shields.io/coveralls/home-assistant/home-assistant.svg - :target: https://coveralls.io/r/home-assistant/home-assistant?branch=master .. |Chat Status| image:: https://img.shields.io/discord/330944238910963714.svg :target: https://discord.gg/c5DvZ4e .. |screenshot-states| image:: https://raw.github.com/home-assistant/home-assistant/master/docs/screenshots.png diff --git a/azure-pipelines.yml b/azure-pipelines-release.yml similarity index 57% rename from azure-pipelines.yml rename to azure-pipelines-release.yml index 7a2967dc495..4f37966f9f5 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines-release.yml @@ -2,108 +2,20 @@ trigger: batch: true - branches: - include: - - dev - - master tags: include: - '*' +pr: none variables: - name: versionBuilder value: '3.2' - - name: versionWheels - value: '0.7' - group: docker - - group: wheels - group: github - group: twine 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.SourceBranchName)" == "dev" ]; then - curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt - else - touch requirements_diff.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' - - job: 'VersionValidate' condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index d63caf9e76f..96417c54b12 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -94,6 +94,13 @@ async def async_from_config_dict(config: Dict[str, Any], stop = time() _LOGGER.info("Home Assistant initialized in %.2fs", stop-start) + if sys.version_info[:3] < (3, 6, 0): + hass.components.persistent_notification.async_create( + "Python 3.5 support is deprecated and will " + "be removed in the first release after August 1. Please " + "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: diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 88217b026fd..a5b6d26d4fd 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -118,7 +118,7 @@ async def async_setup(hass, config): tasks = [alert.async_update_ha_state() for alert in entities] if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) return True diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 6918ec1e54f..0717532f64d 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -39,7 +39,7 @@ class Auth: self._prefs = None self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) - self._get_token_lock = asyncio.Lock(loop=hass.loop) + self._get_token_lock = asyncio.Lock() async def async_do_auth(self, accept_grant_code): """Do authentication with an AcceptGrant code.""" @@ -97,7 +97,7 @@ class Auth: try: session = aiohttp_client.async_get_clientsession(self.hass) - with async_timeout.timeout(DEFAULT_TIMEOUT, loop=self.hass.loop): + with async_timeout.timeout(DEFAULT_TIMEOUT): response = await session.post(LWA_TOKEN_URI, headers=LWA_HEADERS, data=lwa_params, diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 184aee9a440..a69a0cf6ec7 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1432,7 +1432,7 @@ async def async_send_changereport_message(hass, config, alexa_entity): try: session = aiohttp_client.async_get_clientsession(hass) - with async_timeout.timeout(DEFAULT_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(DEFAULT_TIMEOUT): response = await session.post(config.endpoint, headers=headers, json=message_serialized, diff --git a/homeassistant/components/ambiclimate/.translations/es.json b/homeassistant/components/ambiclimate/.translations/es.json new file mode 100644 index 00000000000..6447926f64e --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Error desconocido al generar un token de acceso.", + "already_setup": "La cuenta de Ambiclimate est\u00e1 configurada.", + "no_config": "Es necesario configurar Ambiclimate antes de poder autenticarse con \u00e9l. [Por favor, lee las instrucciones](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Autenticado correctamente con Ambiclimate" + }, + "error": { + "follow_link": "Accede al enlace e identif\u00edcate antes de pulsar Enviar.", + "no_token": "No autenticado con Ambiclimate" + }, + "step": { + "auth": { + "description": "Accede al siguiente [enlace]({authorization_url}) y permite el acceso a tu cuenta de Ambiclimate, despu\u00e9s vuelve y pulsa en enviar a continuaci\u00f3n.\n(Aseg\u00farate que la url de devoluci\u00f3n de llamada es {cb_url})", + "title": "Autenticaci\u00f3n de Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/fr.json b/homeassistant/components/ambiclimate/.translations/fr.json new file mode 100644 index 00000000000..6d09fd6ee05 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/fr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Erreur inconnue lors de la g\u00e9n\u00e9ration d'un jeton d'acc\u00e8s.", + "already_setup": "Le compte Ambiclimate est configur\u00e9.", + "no_config": "Vous devez configurer Ambiclimate avant de pouvoir vous authentifier aupr\u00e8s de celui-ci. [Veuillez lire les instructions] (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Authentifi\u00e9 avec succ\u00e8s avec Ambiclimate" + }, + "error": { + "follow_link": "Veuillez suivre le lien et vous authentifier avant d'appuyer sur Soumettre.", + "no_token": "Non authentifi\u00e9 avec Ambiclimate" + }, + "step": { + "auth": { + "description": "Suivez ce [lien] ( {authorization_url} ) et Autorisez l'acc\u00e8s \u00e0 votre compte Ambiclimate, puis revenez et appuyez sur Envoyer ci-dessous. \n (Assurez-vous que l'URL de rappel sp\u00e9cifi\u00e9 est {cb_url} )", + "title": "Authentifier Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/no.json b/homeassistant/components/ambiclimate/.translations/no.json new file mode 100644 index 00000000000..567d0b95ff3 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Ukjent feil ved oppretting av tilgangstoken.", + "already_setup": "Ambiclimate-kontoen er konfigurert.", + "no_config": "Du m\u00e5 konfigurere Ambiclimate f\u00f8r du kan autentisere med den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Vellykket autentisering med Ambiclimate" + }, + "error": { + "follow_link": "Vennligst f\u00f8lg lenken og godkjen f\u00f8r du trykker p\u00e5 Send", + "no_token": "Ikke autentisert med Ambiclimate" + }, + "step": { + "auth": { + "description": "Vennligst f\u00f8lg denne [linken]({authorization_url}) og Tillat tilgang til din Ambiclimate konto, og kom s\u00e5 tilbake og trykk Send nedenfor.\n(Kontroller at den angitte URL-adressen for tilbakeringing er {cb_url})", + "title": "Autensiere Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/pl.json b/homeassistant/components/ambiclimate/.translations/pl.json new file mode 100644 index 00000000000..dac6e52dda2 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/pl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Nieznany b\u0142\u0105d podczas generowania tokena dost\u0119pu.", + "already_setup": "Konto Ambiclimate jest skonfigurowane.", + "no_config": "Musisz skonfigurowa\u0107 Ambiclimate, zanim b\u0119dziesz m\u00f3g\u0142 si\u0119 z nim uwierzytelni\u0107. [Przeczytaj instrukcj\u0119](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Pomy\u015blnie uwierzytelniono z Ambiclimate" + }, + "error": { + "follow_link": "Prosz\u0119 klikn\u0105\u0107 link i uwierzytelni\u0107 przed naci\u015bni\u0119ciem przycisku Prze\u015blij", + "no_token": "Nie uwierzytelniony z Ambiclimate" + }, + "step": { + "auth": { + "description": "Kliknij poni\u017cszy [link]({authorization_url}) i Zezw\u00f3l na dost\u0119p do swojego konta Ambiclimate, a nast\u0119pnie wr\u00f3\u0107 i naci\u015bnij Prze\u015blij poni\u017cej. \n(Upewnij si\u0119, \u017ce podany adres URL to {cb_url})", + "title": "Uwierzytelnienie Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/sl.json b/homeassistant/components/ambiclimate/.translations/sl.json new file mode 100644 index 00000000000..cae2e940d56 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/sl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Neznana napaka pri ustvarjanju \u017eetona za dostop.", + "already_setup": "Ra\u010dun Ambiclimate je konfiguriran.", + "no_config": "Ambiclimat morate konfigurirati, preden lahko z njo preverjate pristnost. [Preberite navodila] (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Uspe\u0161no overjeno z funkcijo Ambiclimate" + }, + "error": { + "follow_link": "Preden pritisnete Po\u0161lji, sledite povezavi in preverite pristnost", + "no_token": "Ni overjeno z Ambiclimate" + }, + "step": { + "auth": { + "description": "Sledite temu povezavi ( {authorization_url} in Dovoli dostopu do svojega ra\u010duna Ambiclimate, nato se vrnite in pritisnite Po\u0161lji spodaj. \n (Poskrbite, da je dolo\u010den url za povratni klic {cb_url} )", + "title": "Overi Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/sv.json b/homeassistant/components/ambiclimate/.translations/sv.json new file mode 100644 index 00000000000..f52bb6697f9 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/sv.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Ok\u00e4nt fel vid generering av \u00e5tkomsttoken.", + "already_setup": "Ambiclientkontot \u00e4r konfigurerat", + "no_config": "Du m\u00e5ste konfigurera Ambiclimate innan du kan autentisera med den. [V\u00e4nligen l\u00e4s instruktionerna] (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Lyckad autentisering med Ambiclimate" + }, + "error": { + "follow_link": "V\u00e4nligen f\u00f6lj l\u00e4nken och autentisera dig innan du trycker p\u00e5 Skicka", + "no_token": "Inte autentiserad med Ambiclimate" + }, + "step": { + "auth": { + "description": "V\u00e4nligen f\u00f6lj denna [l\u00e4nk] ({authorization_url}) och till\u00e5ta till g\u00e5ng till ditt Ambiclimate konto, kom sedan tillbaka och tryck p\u00e5 Skicka nedan.\n(Kontrollera att den angivna callback url \u00e4r {cb_url})", + "title": "Autentisera Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py index d326a943761..ae61163ab05 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -62,7 +62,7 @@ async def async_setup_entry(hass, entry, async_add_entities): return if _token_info: - await store.async_save(token_info) + await store.async_save(_token_info) token_info = _token_info data_connection = ambiclimate.AmbiclimateConnection(oauth, diff --git a/homeassistant/components/ambiclimate/manifest.json b/homeassistant/components/ambiclimate/manifest.json index f3b3450f163..70c05704873 100644 --- a/homeassistant/components/ambiclimate/manifest.json +++ b/homeassistant/components/ambiclimate/manifest.json @@ -1,9 +1,10 @@ { "domain": "ambiclimate", "name": "Ambiclimate", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/ambiclimate", "requirements": [ - "ambiclimate==0.1.1" + "ambiclimate==0.1.2" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index 11d2ad3668e..3e9bbf6a5b8 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -1,6 +1,7 @@ { "domain": "ambient_station", "name": "Ambient station", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/ambient_station", "requirements": [ "aioambient==0.3.0" diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index e646c11f2e9..d75475dbb26 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -203,8 +203,7 @@ class AmcrestCam(Camera): """Return the camera model.""" return self._model - @property - def stream_source(self): + async def stream_source(self): """Return the source of the stream.""" return self._api.rtsp_url(typeno=self._resolution) diff --git a/homeassistant/components/android_ip_webcam/__init__.py b/homeassistant/components/android_ip_webcam/__init__.py index dfb6d143e9a..c9357c4cce0 100644 --- a/homeassistant/components/android_ip_webcam/__init__.py +++ b/homeassistant/components/android_ip_webcam/__init__.py @@ -233,7 +233,7 @@ async def async_setup(hass, config): tasks = [async_setup_ipcamera(conf) for conf in config[DOMAIN]] if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) return True diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 030f0425df0..efdd32ecbc5 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -90,20 +90,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if CONF_ADB_SERVER_IP not in config: # Use "python-adb" (Python ADB implementation) + adb_log = "using Python ADB implementation " if CONF_ADBKEY in config: aftv = setup(host, config[CONF_ADBKEY], device_class=config[CONF_DEVICE_CLASS]) - adb_log = " using adbkey='{0}'".format(config[CONF_ADBKEY]) + adb_log += "with adbkey='{0}'".format(config[CONF_ADBKEY]) else: aftv = setup(host, device_class=config[CONF_DEVICE_CLASS]) - adb_log = "" + adb_log += "without adbkey authentication" else: # Use "pure-python-adb" (communicate with ADB server) aftv = setup(host, adb_server_ip=config[CONF_ADB_SERVER_IP], adb_server_port=config[CONF_ADB_SERVER_PORT], device_class=config[CONF_DEVICE_CLASS]) - adb_log = " using ADB server at {0}:{1}".format( + adb_log = "using ADB server at {0}:{1}".format( config[CONF_ADB_SERVER_IP], config[CONF_ADB_SERVER_PORT]) if not aftv.available: @@ -117,7 +118,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): else: device_name = 'Android TV / Fire TV device' - _LOGGER.warning("Could not connect to %s at %s%s", + _LOGGER.warning("Could not connect to %s at %s %s", device_name, host, adb_log) raise PlatformNotReady @@ -156,10 +157,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for target_device in target_devices: output = target_device.adb_command(cmd) - # log the output if there is any - if output and (not isinstance(output, str) or output.strip()): + # log the output, if there is any + if output: _LOGGER.info("Output of command '%s' from '%s': %s", - cmd, target_device.entity_id, repr(output)) + cmd, target_device.entity_id, output) hass.services.register(ANDROIDTV_DOMAIN, SERVICE_ADB_COMMAND, service_adb_command, @@ -224,6 +225,7 @@ class ADBDevice(MediaPlayerDevice): self.exceptions = (ConnectionResetError, RuntimeError) # Property attributes + self._adb_response = None self._available = self.aftv.available self._current_app = None self._state = None @@ -243,6 +245,11 @@ class ADBDevice(MediaPlayerDevice): """Return whether or not the ADB connection is valid.""" return self._available + @property + def device_state_attributes(self): + """Provide the last ADB command's response as an attribute.""" + return {'adb_response': self._adb_response} + @property def name(self): """Return the device name.""" @@ -304,12 +311,24 @@ class ADBDevice(MediaPlayerDevice): """Send an ADB command to an Android TV / Fire TV device.""" key = self._keys.get(cmd) if key: - return self.aftv.adb_shell('input keyevent {}'.format(key)) + self.aftv.adb_shell('input keyevent {}'.format(key)) + self._adb_response = None + self.schedule_update_ha_state() + return if cmd == 'GET_PROPERTIES': - return self.aftv.get_properties_dict() + self._adb_response = str(self.aftv.get_properties_dict()) + self.schedule_update_ha_state() + return self._adb_response - return self.aftv.adb_shell(cmd) + response = self.aftv.adb_shell(cmd) + if isinstance(response, str) and response.strip(): + self._adb_response = response.strip() + else: + self._adb_response = None + + self.schedule_update_ha_state() + return self._adb_response class AndroidTVDevice(ADBDevice): diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 1a335fc2ce6..13c0ef338bc 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -47,7 +47,7 @@ async def async_setup_platform(hass, config, async_add_entities, hass.async_create_task(device.async_update_ha_state()) avr = await anthemav.Connection.create( - host=host, port=port, loop=hass.loop, + host=host, port=port, update_callback=async_anthemav_update_callback) device = AnthemAVR(avr, name) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 0e860854af4..feea4f21c9c 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -82,7 +82,7 @@ class APIEventStream(HomeAssistantView): raise Unauthorized() hass = request.app['hass'] stop_obj = object() - to_write = asyncio.Queue(loop=hass.loop) + to_write = asyncio.Queue() restrict = request.query.get('restrict') if restrict: @@ -119,8 +119,7 @@ class APIEventStream(HomeAssistantView): while True: try: - with async_timeout.timeout(STREAM_PING_INTERVAL, - loop=hass.loop): + with async_timeout.timeout(STREAM_PING_INTERVAL): payload = await to_write.get() if payload is stop_obj: diff --git a/homeassistant/components/apns/notify.py b/homeassistant/components/apns/notify.py index 365bdbcb4f5..ccf7c495f39 100644 --- a/homeassistant/components/apns/notify.py +++ b/homeassistant/components/apns/notify.py @@ -1,6 +1,5 @@ """APNS Notification platform.""" import logging -import os import voluptuous as vol @@ -149,7 +148,8 @@ class ApnsNotificationService(BaseNotificationService): self.devices = {} self.device_states = {} self.topic = topic - if os.path.isfile(self.yaml_path): + + try: self.devices = { str(key): ApnsDevice( str(key), @@ -160,6 +160,8 @@ class ApnsNotificationService(BaseNotificationService): for (key, value) in load_yaml_config_file(self.yaml_path).items() } + except FileNotFoundError: + pass tracking_ids = [ device.full_tracking_device_id diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index 0ebe29ed47c..80da26195ee 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -167,7 +167,7 @@ async def async_setup(hass, config): tasks = [_setup_atv(hass, config, conf) for conf in config.get(DOMAIN, [])] if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) hass.services.async_register( DOMAIN, SERVICE_SCAN, async_service_handler, diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index fa8b77da768..beca5cd236c 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -124,7 +124,7 @@ async def async_setup(hass, config): context=service_call.context)) if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) async def turn_onoff_service_handler(service_call): """Handle automation turn on/off service calls.""" @@ -134,7 +134,7 @@ async def async_setup(hass, config): tasks.append(getattr(entity, method)()) if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) async def toggle_service_handler(service_call): """Handle automation toggle service calls.""" @@ -146,7 +146,7 @@ async def async_setup(hass, config): tasks.append(entity.async_turn_on()) if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) async def reload_service_handler(service_call): """Remove all automations and load new ones from config.""" diff --git a/homeassistant/components/aws/__init__.py b/homeassistant/components/aws/__init__.py index e25af68d550..5b9978fb3e6 100644 --- a/homeassistant/components/aws/__init__.py +++ b/homeassistant/components/aws/__init__.py @@ -166,14 +166,14 @@ async def _validate_aws_credentials(hass, credential): profile = aws_config.get(CONF_PROFILE_NAME) if profile is not None: - session = aiobotocore.AioSession(profile=profile, loop=hass.loop) + session = aiobotocore.AioSession(profile=profile) del aws_config[CONF_PROFILE_NAME] if CONF_ACCESS_KEY_ID in aws_config: del aws_config[CONF_ACCESS_KEY_ID] if CONF_SECRET_ACCESS_KEY in aws_config: del aws_config[CONF_SECRET_ACCESS_KEY] else: - session = aiobotocore.AioSession(loop=hass.loop) + session = aiobotocore.AioSession() if credential[CONF_VALIDATE]: async with session.create_client("iam", **aws_config) as client: diff --git a/homeassistant/components/aws/notify.py b/homeassistant/components/aws/notify.py index 3a6193f403d..4b71ae425cb 100644 --- a/homeassistant/components/aws/notify.py +++ b/homeassistant/components/aws/notify.py @@ -94,10 +94,10 @@ async def async_get_service(hass, config, discovery_info=None): if session is None: profile = aws_config.get(CONF_PROFILE_NAME) if profile is not None: - session = aiobotocore.AioSession(profile=profile, loop=hass.loop) + session = aiobotocore.AioSession(profile=profile) del aws_config[CONF_PROFILE_NAME] else: - session = aiobotocore.AioSession(loop=hass.loop) + session = aiobotocore.AioSession() aws_config[CONF_REGION] = region_name diff --git a/homeassistant/components/axis/.translations/nl.json b/homeassistant/components/axis/.translations/nl.json new file mode 100644 index 00000000000..e46f35aa1f9 --- /dev/null +++ b/homeassistant/components/axis/.translations/nl.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "device_unavailable": "Apparaat is niet beschikbaar", + "faulty_credentials": "Ongeldige gebruikersreferenties" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "port": "Poort", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/sv.json b/homeassistant/components/axis/.translations/sv.json index 435a56632e8..2f75a9dcfff 100644 --- a/homeassistant/components/axis/.translations/sv.json +++ b/homeassistant/components/axis/.translations/sv.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Enheten \u00e4r redan konfigurerad", - "bad_config_file": "Felaktig data fr\u00e5n config fil" + "bad_config_file": "Felaktig data fr\u00e5n config fil", + "link_local_address": "Link local addresses are not supported" }, "error": { "already_configured": "Enheten \u00e4r redan konfigurerad", @@ -17,7 +18,7 @@ "port": "Port", "username": "Anv\u00e4ndarnamn" }, - "title": "Konfigurera Axis enhet" + "title": "Konfigurera Axis-enhet" } }, "title": "Axis enhet" diff --git a/homeassistant/components/axis/axis_base.py b/homeassistant/components/axis/axis_base.py new file mode 100644 index 00000000000..9a8f53c8bde --- /dev/null +++ b/homeassistant/components/axis/axis_base.py @@ -0,0 +1,86 @@ +"""Base classes for Axis entities.""" + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN as AXIS_DOMAIN + + +class AxisEntityBase(Entity): + """Base common to all Axis entities.""" + + def __init__(self, device): + """Initialize the Axis event.""" + self.device = device + self.unsub_dispatcher = [] + + async def async_added_to_hass(self): + """Subscribe device events.""" + self.unsub_dispatcher.append(async_dispatcher_connect( + self.hass, self.device.event_reachable, self.update_callback)) + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe device events when removed.""" + for unsub_dispatcher in self.unsub_dispatcher: + unsub_dispatcher() + + @property + def available(self): + """Return True if device is available.""" + return self.device.available + + @property + def device_info(self): + """Return a device description for device registry.""" + return { + 'identifiers': {(AXIS_DOMAIN, self.device.serial)} + } + + @callback + def update_callback(self, no_delay=None): + """Update the entities state.""" + self.async_schedule_update_ha_state() + + +class AxisEventBase(AxisEntityBase): + """Base common to all Axis entities from event stream.""" + + def __init__(self, event, device): + """Initialize the Axis event.""" + super().__init__(device) + self.event = event + + async def async_added_to_hass(self) -> None: + """Subscribe sensors events.""" + self.event.register_callback(self.update_callback) + + await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + """Disconnect device object when removed.""" + self.event.remove_callback(self.update_callback) + + await super().async_will_remove_from_hass() + + @property + def device_class(self): + """Return the class of the event.""" + return self.event.CLASS + + @property + def name(self): + """Return the name of the event.""" + return '{} {} {}'.format( + self.device.name, self.event.TYPE, self.event.id) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return '{}-{}-{}'.format( + self.device.serial, self.event.topic, self.event.id) diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index e9ef9f63710..86a2a738b70 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -2,6 +2,8 @@ from datetime import timedelta +from axis.event_stream import CLASS_INPUT, CLASS_OUTPUT + from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.const import CONF_MAC, CONF_TRIGGER_TIME from homeassistant.core import callback @@ -9,7 +11,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow -from .const import DOMAIN as AXIS_DOMAIN, LOGGER +from .axis_base import AxisEventBase +from .const import DOMAIN as AXIS_DOMAIN async def async_setup_entry(hass, config_entry, async_add_entities): @@ -21,32 +24,21 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def async_add_sensor(event_id): """Add binary sensor from Axis device.""" event = device.api.event.events[event_id] - async_add_entities([AxisBinarySensor(event, device)], True) + + if event.CLASS != CLASS_OUTPUT: + async_add_entities([AxisBinarySensor(event, device)], True) device.listeners.append(async_dispatcher_connect( hass, device.event_new_sensor, async_add_sensor)) -class AxisBinarySensor(BinarySensorDevice): +class AxisBinarySensor(AxisEventBase, BinarySensorDevice): """Representation of a binary Axis event.""" def __init__(self, event, device): """Initialize the Axis binary sensor.""" - self.event = event - self.device = device + super().__init__(event, device) self.remove_timer = None - self.unsub_dispatcher = None - - async def async_added_to_hass(self): - """Subscribe sensors events.""" - self.event.register_callback(self.update_callback) - self.unsub_dispatcher = async_dispatcher_connect( - self.hass, self.device.event_reachable, self.update_callback) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect device object when removed.""" - self.event.remove_callback(self.update_callback) - self.unsub_dispatcher() @callback def update_callback(self, no_delay=False): @@ -67,7 +59,6 @@ class AxisBinarySensor(BinarySensorDevice): @callback def _delay_update(now): """Timer callback for sensor update.""" - LOGGER.debug("%s called delayed (%s sec) update", self.name, delay) self.async_schedule_update_ha_state() self.remove_timer = None @@ -83,32 +74,10 @@ class AxisBinarySensor(BinarySensorDevice): @property def name(self): """Return the name of the event.""" - return '{} {} {}'.format( - self.device.name, self.event.TYPE, self.event.id) + if self.event.CLASS == CLASS_INPUT and self.event.id and \ + self.device.api.vapix.ports[self.event.id].name: + return '{} {}'.format( + self.device.name, + self.device.api.vapix.ports[self.event.id].name) - @property - def device_class(self): - """Return the class of the event.""" - return self.event.CLASS - - @property - def unique_id(self): - """Return a unique identifier for this device.""" - return '{}-{}-{}'.format( - self.device.serial, self.event.topic, self.event.id) - - def available(self): - """Return True if device is available.""" - return self.device.available - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def device_info(self): - """Return a device description for device registry.""" - return { - 'identifiers': {(AXIS_DOMAIN, self.device.serial)} - } + return super().name diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index 457cc23e73d..c993e9d9f64 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -6,9 +6,9 @@ from homeassistant.components.mjpeg.camera import ( from homeassistant.const import ( CONF_AUTHENTICATION, CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, HTTP_DIGEST_AUTHENTICATION) -from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .axis_base import AxisEntityBase from .const import DOMAIN as AXIS_DOMAIN AXIS_IMAGE = 'http://{}:{}/axis-cgi/jpg/image.cgi' @@ -38,65 +38,40 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities([AxisCamera(config, device)]) -class AxisCamera(MjpegCamera): +class AxisCamera(AxisEntityBase, MjpegCamera): """Representation of a Axis camera.""" def __init__(self, config, device): """Initialize Axis Communications camera component.""" - super().__init__(config) - self.device_config = config - self.device = device - self.port = device.config_entry.data[CONF_DEVICE][CONF_PORT] - self.unsub_dispatcher = [] + AxisEntityBase.__init__(self, device) + MjpegCamera.__init__(self, config) async def async_added_to_hass(self): """Subscribe camera events.""" self.unsub_dispatcher.append(async_dispatcher_connect( self.hass, self.device.event_new_address, self._new_address)) - self.unsub_dispatcher.append(async_dispatcher_connect( - self.hass, self.device.event_reachable, self.update_callback)) - async def async_will_remove_from_hass(self) -> None: - """Disconnect device object when removed.""" - for unsub_dispatcher in self.unsub_dispatcher: - unsub_dispatcher() + await super().async_added_to_hass() @property def supported_features(self): """Return supported features.""" return SUPPORT_STREAM - @property - def stream_source(self): + async def stream_source(self): """Return the stream source.""" return AXIS_STREAM.format( self.device.config_entry.data[CONF_DEVICE][CONF_USERNAME], self.device.config_entry.data[CONF_DEVICE][CONF_PASSWORD], self.device.host) - @callback - def update_callback(self, no_delay=None): - """Update the cameras state.""" - self.async_schedule_update_ha_state() - - @property - def available(self): - """Return True if device is available.""" - return self.device.available - def _new_address(self): """Set new device address for video stream.""" - self._mjpeg_url = AXIS_VIDEO.format(self.device.host, self.port) - self._still_image_url = AXIS_IMAGE.format(self.device.host, self.port) + port = self.device.config_entry.data[CONF_DEVICE][CONF_PORT] + self._mjpeg_url = AXIS_VIDEO.format(self.device.host, port) + self._still_image_url = AXIS_IMAGE.format(self.device.host, port) @property def unique_id(self): """Return a unique identifier for this device.""" return '{}-camera'.format(self.device.serial) - - @property - def device_info(self): - """Return a device description for device registry.""" - return { - 'identifiers': {(AXIS_DOMAIN, self.device.serial)} - } diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 0c175de20c7..2aa5c4de16e 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -146,7 +146,7 @@ class AxisFlowHandler(config_entries.ConfigFlow): entry.data[CONF_DEVICE][CONF_HOST] = host self.hass.config_entries.async_update_entry(entry) - async def async_step_discovery(self, discovery_info): + async def async_step_zeroconf(self, discovery_info): """Prepare configuration for a discovered Axis device. This flow is triggered by the discovery component. @@ -155,6 +155,13 @@ class AxisFlowHandler(config_entries.ConfigFlow): return self.async_abort(reason='link_local_address') serialnumber = discovery_info['properties']['macaddress'] + # pylint: disable=unsupported-assignment-operation + self.context['macaddress'] = serialnumber + + if any(serialnumber == flow['context']['macaddress'] + for flow in self._async_in_progress()): + return self.async_abort(reason='already_in_progress') + device_entries = configured_devices(self.hass) if serialnumber in device_entries: diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 1595dde4cba..32c5ac090e9 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -83,19 +83,23 @@ class AxisNetworkDevice: self.product_type = self.api.vapix.params.prodtype if self.config_entry.options[CONF_CAMERA]: + self.hass.async_create_task( self.hass.config_entries.async_forward_entry_setup( self.config_entry, 'camera')) if self.config_entry.options[CONF_EVENTS]: - task = self.hass.async_create_task( - self.hass.config_entries.async_forward_entry_setup( - self.config_entry, 'binary_sensor')) self.api.stream.connection_status_callback = \ self.async_connection_status_callback self.api.enable_events(event_callback=self.async_event_callback) - task.add_done_callback(self.start) + + platform_tasks = [ + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, platform) + for platform in ['binary_sensor', 'switch'] + ] + self.hass.async_create_task(self.start(platform_tasks)) self.config_entry.add_update_listener(self.async_new_address_callback) @@ -145,9 +149,9 @@ class AxisNetworkDevice: if action == 'add': async_dispatcher_send(self.hass, self.event_new_sensor, event_id) - @callback - def start(self, fut): - """Start the event stream.""" + async def start(self, platform_tasks): + """Start the event stream when all platforms are loaded.""" + await asyncio.gather(*platform_tasks) self.api.start() @callback @@ -157,15 +161,22 @@ class AxisNetworkDevice: async def async_reset(self): """Reset this device to default state.""" - self.api.stop() + platform_tasks = [] if self.config_entry.options[CONF_CAMERA]: - await self.hass.config_entries.async_forward_entry_unload( - self.config_entry, 'camera') + platform_tasks.append( + self.hass.config_entries.async_forward_entry_unload( + self.config_entry, 'camera')) if self.config_entry.options[CONF_EVENTS]: - await self.hass.config_entries.async_forward_entry_unload( - self.config_entry, 'binary_sensor') + self.api.stop() + platform_tasks += [ + self.hass.config_entries.async_forward_entry_unload( + self.config_entry, platform) + for platform in ['binary_sensor', 'switch'] + ] + + await asyncio.gather(*platform_tasks) for unsub_dispatcher in self.listeners: unsub_dispatcher() @@ -185,13 +196,22 @@ async def get_device(hass, config): port=config[CONF_PORT], web_proto='http') device.vapix.initialize_params(preload_data=False) + device.vapix.initialize_ports() try: with async_timeout.timeout(15): - await hass.async_add_executor_job( - device.vapix.params.update_brand) - await hass.async_add_executor_job( - device.vapix.params.update_properties) + + await asyncio.gather( + hass.async_add_executor_job( + device.vapix.params.update_brand), + + hass.async_add_executor_job( + device.vapix.params.update_properties), + + hass.async_add_executor_job( + device.vapix.ports.update) + ) + return device except axis.Unauthorized: diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index f87718bfddd..dc64e90ba9a 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -1,8 +1,10 @@ { "domain": "axis", "name": "Axis", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/axis", - "requirements": ["axis==22"], + "requirements": ["axis==24"], "dependencies": [], + "zeroconf": ["_axis-video._tcp.local."], "codeowners": ["@kane610"] } diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json index 3c528dfbb16..ebefbecf311 100644 --- a/homeassistant/components/axis/strings.json +++ b/homeassistant/components/axis/strings.json @@ -14,6 +14,7 @@ }, "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/switch.py b/homeassistant/components/axis/switch.py new file mode 100644 index 00000000000..852528120a5 --- /dev/null +++ b/homeassistant/components/axis/switch.py @@ -0,0 +1,59 @@ +"""Support for Axis switches.""" + +from axis.event_stream import CLASS_OUTPUT + +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import CONF_MAC +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .axis_base import AxisEventBase +from .const import DOMAIN as AXIS_DOMAIN + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up a Axis switch.""" + serial_number = config_entry.data[CONF_MAC] + device = hass.data[AXIS_DOMAIN][serial_number] + + @callback + def async_add_switch(event_id): + """Add switch from Axis device.""" + event = device.api.event.events[event_id] + + if event.CLASS == CLASS_OUTPUT: + async_add_entities([AxisSwitch(event, device)], True) + + device.listeners.append(async_dispatcher_connect( + hass, device.event_new_sensor, async_add_switch)) + + +class AxisSwitch(AxisEventBase, SwitchDevice): + """Representation of a Axis switch.""" + + @property + def is_on(self): + """Return true if event is active.""" + return self.event.is_tripped + + async def async_turn_on(self, **kwargs): + """Turn on switch.""" + action = '/' + await self.hass.async_add_executor_job( + self.device.api.vapix.ports[self.event.id].action, action) + + async def async_turn_off(self, **kwargs): + """Turn off switch.""" + action = '\\' + await self.hass.async_add_executor_job( + self.device.api.vapix.ports[self.event.id].action, action) + + @property + def name(self): + """Return the name of the event.""" + if self.event.id and self.device.api.vapix.ports[self.event.id].name: + return '{} {}'.format( + self.device.name, + self.device.api.vapix.ports[self.event.id].name) + + return super().name diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py new file mode 100644 index 00000000000..c5362fe1821 --- /dev/null +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -0,0 +1,80 @@ +"""Support for Azure Event Hubs.""" +import json +import logging +from typing import Any, Dict + +import voluptuous as vol +from azure.eventhub import EventData, EventHubClientAsync + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, STATE_UNAVAILABLE, + STATE_UNKNOWN) +from homeassistant.core import Event, HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entityfilter import FILTER_SCHEMA +from homeassistant.helpers.json import JSONEncoder + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'azure_event_hub' + +CONF_EVENT_HUB_NAMESPACE = 'event_hub_namespace' +CONF_EVENT_HUB_INSTANCE_NAME = 'event_hub_instance_name' +CONF_EVENT_HUB_SAS_POLICY = 'event_hub_sas_policy' +CONF_EVENT_HUB_SAS_KEY = 'event_hub_sas_key' +CONF_FILTER = 'filter' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_EVENT_HUB_NAMESPACE): cv.string, + vol.Required(CONF_EVENT_HUB_INSTANCE_NAME): cv.string, + vol.Required(CONF_EVENT_HUB_SAS_POLICY): cv.string, + vol.Required(CONF_EVENT_HUB_SAS_KEY): cv.string, + vol.Required(CONF_FILTER): FILTER_SCHEMA, + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): + """Activate Azure EH component.""" + config = yaml_config[DOMAIN] + + event_hub_address = "amqps://{}.servicebus.windows.net/{}".format( + config[CONF_EVENT_HUB_NAMESPACE], + config[CONF_EVENT_HUB_INSTANCE_NAME]) + entities_filter = config[CONF_FILTER] + + client = EventHubClientAsync( + event_hub_address, + debug=True, + username=config[CONF_EVENT_HUB_SAS_POLICY], + password=config[CONF_EVENT_HUB_SAS_KEY]) + async_sender = client.add_async_sender() + await client.run_async() + + encoder = JSONEncoder() + + async def async_send_to_event_hub(event: Event): + """Send states to Event Hub.""" + state = event.data.get('new_state') + if (state is None + or state.state in (STATE_UNKNOWN, '', STATE_UNAVAILABLE) + or not entities_filter(state.entity_id)): + return + + event_data = EventData( + json.dumps( + obj=state.as_dict(), + default=encoder.encode + ).encode('utf-8') + ) + await async_sender.send(event_data) + + async def async_shutdown(event: Event): + """Shut down the client.""" + await client.stop_async() + + hass.bus.async_listen(EVENT_STATE_CHANGED, async_send_to_event_hub) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_shutdown) + + return True diff --git a/homeassistant/components/azure_event_hub/manifest.json b/homeassistant/components/azure_event_hub/manifest.json new file mode 100644 index 00000000000..e2223fc97c3 --- /dev/null +++ b/homeassistant/components/azure_event_hub/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "azure_event_hub", + "name": "Azure Event Hub", + "documentation": "https://www.home-assistant.io/components/azure_event_hub", + "requirements": ["azure-eventhub==1.3.1"], + "dependencies": [], + "codeowners": ["@eavanvalkenburg"] + } \ No newline at end of file diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 397ee097cae..74057c7b6bc 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -8,7 +8,7 @@ from homeassistant.helpers import ( from homeassistant.const import ( CONF_USERNAME, CONF_PASSWORD, CONF_NAME, CONF_SCAN_INTERVAL, CONF_BINARY_SENSORS, CONF_SENSORS, CONF_FILENAME, - CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT) + CONF_MONITORED_CONDITIONS, CONF_MODE, CONF_OFFSET, TEMP_FAHRENHEIT) _LOGGER = logging.getLogger(__name__) @@ -41,7 +41,7 @@ BINARY_SENSORS = { SENSORS = { TYPE_TEMPERATURE: ['Temperature', TEMP_FAHRENHEIT, 'mdi:thermometer'], - TYPE_BATTERY: ['Battery', '%', 'mdi:battery-80'], + TYPE_BATTERY: ['Battery', '', 'mdi:battery-80'], TYPE_WIFI_STRENGTH: ['Wifi Signal', 'dBm', 'mdi:wifi-strength-2'], } @@ -75,6 +75,8 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_BINARY_SENSORS, default={}): BINARY_SENSOR_SCHEMA, vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + vol.Optional(CONF_OFFSET, default=1): int, + vol.Optional(CONF_MODE, default=''): cv.string, }) }, extra=vol.ALLOW_EXTRA) @@ -87,8 +89,12 @@ def setup(hass, config): username = conf[CONF_USERNAME] password = conf[CONF_PASSWORD] scan_interval = conf[CONF_SCAN_INTERVAL] + is_legacy = bool(conf[CONF_MODE] == 'legacy') + motion_interval = conf[CONF_OFFSET] hass.data[BLINK_DATA] = blinkpy.Blink(username=username, - password=password) + password=password, + motion_interval=motion_interval, + legacy_subdomain=is_legacy) hass.data[BLINK_DATA].refresh_rate = scan_interval.total_seconds() hass.data[BLINK_DATA].start() diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 7be44f95a53..abce8a4a0d1 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.13.1" + "blinkpy==0.14.0" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 080afeea280..2a3b3e35125 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -255,7 +255,7 @@ class BluesoundPlayer(MediaPlayerDevice): BluesoundPlayer._TimeoutException): _LOGGER.info("Node %s is offline, retrying later", self._name) await asyncio.sleep( - NODE_OFFLINE_CHECK_TIMEOUT, loop=self._hass.loop) + NODE_OFFLINE_CHECK_TIMEOUT) self.start_polling() except CancelledError: @@ -318,7 +318,7 @@ class BluesoundPlayer(MediaPlayerDevice): try: websession = async_get_clientsession(self._hass) - with async_timeout.timeout(10, loop=self._hass.loop): + with async_timeout.timeout(10): response = await websession.get(url) if response.status == 200: @@ -361,7 +361,7 @@ class BluesoundPlayer(MediaPlayerDevice): try: - with async_timeout.timeout(125, loop=self._hass.loop): + with async_timeout.timeout(125): response = await self._polling_session.get( url, headers={CONNECTION: KEEP_ALIVE}) @@ -378,7 +378,7 @@ class BluesoundPlayer(MediaPlayerDevice): self._group_name = group_name # the sleep is needed to make sure that the # devices is synced - await asyncio.sleep(1, loop=self._hass.loop) + await asyncio.sleep(1) await self.async_trigger_sync_on_all() elif self.is_grouped: # when player is grouped we need to fetch volume from diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py index d256f56e7fe..6b5fcd7df06 100644 --- a/homeassistant/components/bluetooth_le_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -2,12 +2,15 @@ import logging from homeassistant.helpers.event import track_point_in_utc_time -from homeassistant.components.device_tracker import ( - YAML_DEVICES, CONF_TRACK_NEW, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, - load_config, SOURCE_TYPE_BLUETOOTH_LE +from homeassistant.components.device_tracker.legacy import ( + YAML_DEVICES, async_load_config +) +from homeassistant.components.device_tracker.const import ( + CONF_TRACK_NEW, CONF_SCAN_INTERVAL, SCAN_INTERVAL, SOURCE_TYPE_BLUETOOTH_LE ) from homeassistant.const import EVENT_HOMEASSISTANT_STOP import homeassistant.util.dt as dt_util +from homeassistant.util.async_ import run_coroutine_threadsafe _LOGGER = logging.getLogger(__name__) @@ -79,7 +82,10 @@ def setup_scanner(hass, config, see, discovery_info=None): # Load all known devices. # We just need the devices so set consider_home and home range # to 0 - for device in load_config(yaml_path, hass, 0): + for device in run_coroutine_threadsafe( + async_load_config(yaml_path, hass, 0), + hass.loop + ).result(): # check if device is a valid bluetooth device if device.mac and device.mac[:4].upper() == BLE_PREFIX: if device.track: @@ -97,7 +103,7 @@ def setup_scanner(hass, config, see, discovery_info=None): _LOGGER.warning("No Bluetooth LE devices to track!") return False - interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) def update_ble(now): """Lookup Bluetooth LE devices and update status.""" diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index d464e87ce64..28b914a94ca 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -5,11 +5,16 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time -from homeassistant.components.device_tracker import ( - YAML_DEVICES, CONF_TRACK_NEW, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, - load_config, PLATFORM_SCHEMA, DEFAULT_TRACK_NEW, SOURCE_TYPE_BLUETOOTH, - DOMAIN) +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.device_tracker.legacy import ( + YAML_DEVICES, async_load_config +) +from homeassistant.components.device_tracker.const import ( + CONF_TRACK_NEW, CONF_SCAN_INTERVAL, SCAN_INTERVAL, DEFAULT_TRACK_NEW, + SOURCE_TYPE_BLUETOOTH, DOMAIN +) import homeassistant.util.dt as dt_util +from homeassistant.util.async_ import run_coroutine_threadsafe _LOGGER = logging.getLogger(__name__) @@ -60,7 +65,10 @@ def setup_scanner(hass, config, see, discovery_info=None): # Load all known devices. # We just need the devices so set consider_home and home range # to 0 - for device in load_config(yaml_path, hass, 0): + for device in run_coroutine_threadsafe( + async_load_config(yaml_path, hass, 0), + hass.loop + ).result(): # Check if device is a valid bluetooth device if device.mac and device.mac[:3].upper() == BT_PREFIX: if device.track: @@ -77,7 +85,7 @@ def setup_scanner(hass, config, see, discovery_info=None): devs_to_track.append(dev[0]) see_device(dev[0], dev[1]) - interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) request_rssi = config.get(CONF_REQUEST_RSSI, False) diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index c542d8f5549..d9a8121e635 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -1,7 +1,6 @@ """Support for the Broadlink RM2 Pro (only temperature) and A1 devices.""" import binascii import logging -import socket from datetime import timedelta import voluptuous as vol @@ -60,6 +59,7 @@ class BroadlinkSensor(Entity): """Initialize the sensor.""" self._name = '{} {}'.format(name, SENSOR_TYPES[sensor_type][0]) self._state = None + self._is_available = False self._type = sensor_type self._broadlink_data = broadlink_data self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] @@ -74,6 +74,11 @@ class BroadlinkSensor(Entity): """Return the state of the sensor.""" return self._state + @property + def available(self): + """Return True if entity is available.""" + return self._is_available + @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" @@ -83,8 +88,11 @@ class BroadlinkSensor(Entity): """Get the latest data from the sensor.""" self._broadlink_data.update() if self._broadlink_data.data is None: + self._state = None + self._is_available = False return self._state = self._broadlink_data.data[self._type] + self._is_available = True class BroadlinkData: @@ -119,8 +127,9 @@ class BroadlinkData: if data is not None: self.data = self._schema(data) return - except socket.timeout as error: + except OSError as error: if retry < 1: + self.data = None _LOGGER.error(error) return except (vol.Invalid, vol.MultipleInvalid): @@ -131,7 +140,7 @@ class BroadlinkData: def _auth(self, retry=3): try: auth = self._device.auth() - except socket.timeout: + except OSError: auth = False if not auth and retry > 0: self._connect() diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index d1b769e3d83..96a45322114 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -10,9 +10,10 @@ from homeassistant.components.switch import ( ENTITY_ID_FORMAT, PLATFORM_SCHEMA, SwitchDevice) from homeassistant.const import ( CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_FRIENDLY_NAME, CONF_HOST, CONF_MAC, - CONF_SWITCHES, CONF_TIMEOUT, CONF_TYPE) + CONF_SWITCHES, CONF_TIMEOUT, CONF_TYPE, STATE_ON) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle, slugify +from homeassistant.helpers.restore_state import RestoreEntity from . import async_setup_service, data_packet @@ -109,13 +110,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): broadlink_device.timeout = config.get(CONF_TIMEOUT) try: broadlink_device.auth() - except socket.timeout: + except OSError: _LOGGER.error("Failed to connect to device") add_entities(switches) -class BroadlinkRMSwitch(SwitchDevice): +class BroadlinkRMSwitch(SwitchDevice, RestoreEntity): """Representation of an Broadlink switch.""" def __init__(self, name, friendly_name, device, command_on, command_off): @@ -126,6 +127,14 @@ class BroadlinkRMSwitch(SwitchDevice): self._command_on = command_on self._command_off = command_off self._device = device + self._is_available = False + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if state: + self._state = state.state == STATE_ON @property def name(self): @@ -137,6 +146,11 @@ class BroadlinkRMSwitch(SwitchDevice): """Return true if unable to access real state of entity.""" return True + @property + def available(self): + """Return True if entity is available.""" + return not self.should_poll or self._is_available + @property def should_poll(self): """Return the polling state.""" @@ -166,7 +180,7 @@ class BroadlinkRMSwitch(SwitchDevice): return True try: self._device.send_data(packet) - except (socket.timeout, ValueError) as error: + except (ValueError, OSError) as error: if retry < 1: _LOGGER.error("Error during sending a packet: %s", error) return False @@ -178,7 +192,7 @@ class BroadlinkRMSwitch(SwitchDevice): def _auth(self, retry=2): try: auth = self._device.auth() - except socket.timeout: + except OSError: auth = False if retry < 1: _LOGGER.error("Timeout during authorization") @@ -244,6 +258,7 @@ class BroadlinkSP2Switch(BroadlinkSP1Switch): except (socket.timeout, ValueError) as error: if retry < 1: _LOGGER.error("Error during updating the state: %s", error) + self._is_available = False return if not self._auth(): return @@ -252,6 +267,7 @@ class BroadlinkSP2Switch(BroadlinkSP1Switch): return self._update(retry-1) self._state = state self._load_power = load_power + self._is_available = True class BroadlinkMP1Slot(BroadlinkRMSwitch): @@ -277,10 +293,12 @@ class BroadlinkMP1Slot(BroadlinkRMSwitch): except (socket.timeout, ValueError) as error: if retry < 1: _LOGGER.error("Error during sending a packet: %s", error) + self._is_available = False return False if not self._auth(): return False return self._sendpacket(packet, max(0, retry-1)) + self._is_available = True return True @property @@ -330,7 +348,7 @@ class BroadlinkMP1Switch: """Authenticate the device.""" try: auth = self._device.auth() - except socket.timeout: + except OSError: auth = False if not auth and retry > 0: return self._auth(retry-1) diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index f3aaa9b7537..71ad6abb914 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -388,7 +388,7 @@ class BrData: tasks.append(dev.async_update_ha_state()) if tasks: - await asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks) async def schedule_update(self, minute=1): """Schedule an update after minute minutes.""" @@ -407,7 +407,7 @@ class BrData: resp = None try: websession = async_get_clientsession(self.hass) - with async_timeout.timeout(10, loop=self.hass.loop): + with async_timeout.timeout(10): resp = await websession.get(url) result[STATUS_CODE] = resp.status diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 73a779816a3..5a1ce79c18c 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -36,7 +36,7 @@ async def async_setup(hass, config): hass.http.register_view(CalendarEventView(component)) # Doesn't work in prod builds of the frontend: home-assistant-polymer#1289 - # await hass.components.frontend.async_register_built_in_panel( + # hass.components.frontend.async_register_built_in_panel( # 'calendar', 'calendar', 'hass:calendar') await component.async_setup(config) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 7a37dffe3b8..b6e41e2cf11 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -107,11 +107,14 @@ async def async_request_stream(hass, entity_id, fmt): camera = _get_camera_from_entity_id(hass, entity_id) camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id) - if not camera.stream_source: + async with async_timeout.timeout(10): + source = await camera.stream_source() + + if not source: raise HomeAssistantError("{} does not support play stream service" .format(camera.entity_id)) - return request_stream(hass, camera.stream_source, fmt=fmt, + return request_stream(hass, source, fmt=fmt, keepalive=camera_prefs.preload_stream) @@ -121,7 +124,7 @@ async def async_get_image(hass, entity_id, timeout=10): camera = _get_camera_from_entity_id(hass, entity_id) with suppress(asyncio.CancelledError, asyncio.TimeoutError): - with async_timeout.timeout(timeout, loop=hass.loop): + async with async_timeout.timeout(timeout): image = await camera.async_camera_image() if image: @@ -221,8 +224,16 @@ async def async_setup(hass, config): async def preload_stream(hass, _): for camera in component.entities: camera_prefs = prefs.get(camera.entity_id) - if camera.stream_source and camera_prefs.preload_stream: - request_stream(hass, camera.stream_source, keepalive=True) + if not camera_prefs.preload_stream: + continue + + async with async_timeout.timeout(10): + source = await camera.stream_source() + + if not source: + continue + + request_stream(hass, source, keepalive=True) async_when_setup(hass, DOMAIN_STREAM, preload_stream) @@ -328,8 +339,7 @@ class Camera(Entity): """Return the interval between frames of the mjpeg stream.""" return 0.5 - @property - def stream_source(self): + async def stream_source(self): """Return the source of the stream.""" return None @@ -481,7 +491,7 @@ class CameraImageView(CameraView): async def handle(self, request, camera): """Serve camera image.""" with suppress(asyncio.CancelledError, asyncio.TimeoutError): - with async_timeout.timeout(10, loop=request.app['hass'].loop): + async with async_timeout.timeout(10): image = await camera.async_camera_image() if image: @@ -522,12 +532,10 @@ async def websocket_camera_thumbnail(hass, connection, msg): """ try: image = await async_get_image(hass, msg['entity_id']) - connection.send_message(websocket_api.result_message( - msg['id'], { - 'content_type': image.content_type, - 'content': base64.b64encode(image.content).decode('utf-8') - } - )) + await connection.send_big_result(msg['id'], { + 'content_type': image.content_type, + 'content': base64.b64encode(image.content).decode('utf-8') + }) except HomeAssistantError: connection.send_message(websocket_api.error_message( msg['id'], 'image_fetch_failed', 'Unable to fetch image')) @@ -549,18 +557,25 @@ async def ws_camera_stream(hass, connection, msg): camera = _get_camera_from_entity_id(hass, entity_id) camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id) - if not camera.stream_source: + async with async_timeout.timeout(10): + source = await camera.stream_source() + + if not source: raise HomeAssistantError("{} does not support play stream service" .format(camera.entity_id)) fmt = msg['format'] - url = request_stream(hass, camera.stream_source, fmt=fmt, + url = request_stream(hass, source, fmt=fmt, keepalive=camera_prefs.preload_stream) connection.send_result(msg['id'], {'url': url}) except HomeAssistantError as ex: - _LOGGER.error(ex) + _LOGGER.error("Error requesting stream: %s", ex) connection.send_error( msg['id'], 'start_stream_failed', str(ex)) + except asyncio.TimeoutError: + _LOGGER.error("Timeout getting stream source") + connection.send_error( + msg['id'], 'start_stream_failed', "Timeout getting stream source") @websocket_api.async_response @@ -624,7 +639,10 @@ async def async_handle_snapshot_service(camera, service): async def async_handle_play_stream_service(camera, service_call): """Handle play stream services calls.""" - if not camera.stream_source: + async with async_timeout.timeout(10): + source = await camera.stream_source() + + if not source: raise HomeAssistantError("{} does not support play stream service" .format(camera.entity_id)) @@ -633,7 +651,7 @@ async def async_handle_play_stream_service(camera, service_call): fmt = service_call.data[ATTR_FORMAT] entity_ids = service_call.data[ATTR_MEDIA_PLAYER] - url = request_stream(hass, camera.stream_source, fmt=fmt, + url = request_stream(hass, source, fmt=fmt, keepalive=camera_prefs.preload_stream) data = { ATTR_ENTITY_ID: entity_ids, @@ -648,7 +666,10 @@ async def async_handle_play_stream_service(camera, service_call): async def async_handle_record_service(camera, call): """Handle stream recording service calls.""" - if not camera.stream_source: + async with async_timeout.timeout(10): + source = await camera.stream_source() + + if not source: raise HomeAssistantError("{} does not support record service" .format(camera.entity_id)) @@ -659,7 +680,7 @@ async def async_handle_record_service(camera, call): variables={ATTR_ENTITY_ID: camera}) data = { - CONF_STREAM_SOURCE: camera.stream_source, + CONF_STREAM_SOURCE: source, CONF_FILENAME: video_path, CONF_DURATION: call.data[CONF_DURATION], CONF_LOOKBACK: call.data[CONF_LOOKBACK], diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 33e1265921f..9411ab2a41c 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -79,7 +79,7 @@ class CanaryCamera(Camera): image = await asyncio.shield(ffmpeg.get_image( self._live_stream_session.live_stream_url, output_format=IMAGE_JPEG, - extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop) + extra_cmd=self._ffmpeg_arguments)) return image async def handle_async_mjpeg_stream(self, request): diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index 1a93020c229..f91b90c1e08 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -1,8 +1,7 @@ """Component to embed Google Cast.""" from homeassistant import config_entries -from homeassistant.helpers import config_entry_flow -DOMAIN = 'cast' +from .const import DOMAIN async def async_setup(hass, config): @@ -23,15 +22,3 @@ async def async_setup_entry(hass, entry): hass.async_create_task(hass.config_entries.async_forward_entry_setup( entry, 'media_player')) return True - - -async def _async_has_devices(hass): - """Return if there are devices that can be discovered.""" - from pychromecast.discovery import discover_chromecasts - - return await hass.async_add_executor_job(discover_chromecasts) - - -config_entry_flow.register_discovery_flow( - DOMAIN, 'Google Cast', _async_has_devices, - config_entries.CONN_CLASS_LOCAL_PUSH) diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py new file mode 100644 index 00000000000..0f8696cf29c --- /dev/null +++ b/homeassistant/components/cast/config_flow.py @@ -0,0 +1,16 @@ +"""Config flow for Cast.""" +from homeassistant.helpers import config_entry_flow +from homeassistant import config_entries +from .const import DOMAIN + + +async def _async_has_devices(hass): + """Return if there are devices that can be discovered.""" + from pychromecast.discovery import discover_chromecasts + + return await hass.async_add_executor_job(discover_chromecasts) + + +config_entry_flow.register_discovery_flow( + DOMAIN, 'Google Cast', _async_has_devices, + config_entries.CONN_CLASS_LOCAL_PUSH) diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py new file mode 100644 index 00000000000..48bb87ca5d7 --- /dev/null +++ b/homeassistant/components/cast/const.py @@ -0,0 +1,3 @@ +"""Consts for Cast integration.""" + +DOMAIN = 'cast' diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index dd189ac91e7..2d310cdda8f 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -1,6 +1,7 @@ { "domain": "cast", "name": "Cast", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/cast", "requirements": [ "pychromecast==3.2.1" diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index 344311aa231..fc751d96602 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -106,7 +106,7 @@ async def async_citybikes_request(hass, uri, schema): try: session = async_get_clientsession(hass) - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): req = await session.get(DEFAULT_ENDPOINT.format(uri=uri)) json_response = await req.json() @@ -181,7 +181,7 @@ class CityBikesNetworks: """Initialize the networks instance.""" self.hass = hass self.networks = None - self.networks_loading = asyncio.Condition(loop=hass.loop) + self.networks_loading = asyncio.Condition() async def get_closest_network_id(self, latitude, longitude): """Return the id of the network closest to provided location.""" @@ -217,7 +217,7 @@ class CityBikesNetwork: self.hass = hass self.network_id = network_id self.stations = [] - self.ready = asyncio.Event(loop=hass.loop) + self.ready = asyncio.Event() async def async_refresh(self, now=None): """Refresh the state of the network.""" diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index f47eae74986..eadb1731bd0 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -17,7 +17,9 @@ from homeassistant.util.aiohttp import MockRequest from . import utils from .const import ( - CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE) + CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE, + PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE, + PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) from .prefs import CloudPreferences @@ -98,12 +100,26 @@ class CloudClient(Interface): if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - return google_conf['filter'](entity.entity_id) + 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, diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 5002286edb9..e2f4b9c0785 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -8,6 +8,13 @@ PREF_ENABLE_REMOTE = 'remote_enabled' PREF_GOOGLE_SECURE_DEVICES_PIN = 'google_secure_devices_pin' PREF_CLOUDHOOKS = 'cloudhooks' PREF_CLOUD_USER = 'cloud_user' +PREF_GOOGLE_ENTITY_CONFIGS = 'google_entity_configs' +PREF_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 CONF_ALEXA = 'alexa' CONF_ALIASES = 'aliases' diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index bf9b7833527..e6151a917af 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -14,8 +14,7 @@ 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.google_assistant import ( - const as google_const) +from homeassistant.components.google_assistant import helpers as google_helpers from .const import ( DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, @@ -81,6 +80,12 @@ async def async_setup(hass): websocket_remote_connect) hass.components.websocket_api.async_register_command( websocket_remote_disconnect) + + hass.components.websocket_api.async_register_command( + google_assistant_list) + hass.components.websocket_api.async_register_command( + google_assistant_update) + hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) @@ -164,10 +169,10 @@ class GoogleActionsSyncView(HomeAssistantView): cloud = hass.data[DOMAIN] websession = hass.helpers.aiohttp_client.async_get_clientsession() - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): await hass.async_add_job(cloud.auth.check_token) - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): req = await websession.post( cloud.google_actions_sync_url, headers={ 'authorization': cloud.id_token @@ -192,7 +197,7 @@ class CloudLoginView(HomeAssistantView): hass = request.app['hass'] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): await hass.async_add_job(cloud.auth.login, data['email'], data['password']) @@ -212,7 +217,7 @@ class CloudLogoutView(HomeAssistantView): hass = request.app['hass'] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): await cloud.logout() return self.json_message('ok') @@ -234,7 +239,7 @@ class CloudRegisterView(HomeAssistantView): hass = request.app['hass'] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): await hass.async_add_job( cloud.auth.register, data['email'], data['password']) @@ -256,7 +261,7 @@ class CloudResendConfirmView(HomeAssistantView): hass = request.app['hass'] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): await hass.async_add_job( cloud.auth.resend_email_confirm, data['email']) @@ -278,7 +283,7 @@ class CloudForgotPasswordView(HomeAssistantView): hass = request.app['hass'] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): await hass.async_add_job( cloud.auth.forgot_password, data['email']) @@ -320,7 +325,7 @@ async def websocket_subscription(hass, connection, msg): from hass_nabucasa.const import STATE_DISCONNECTED cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): response = await cloud.fetch_subscription_info() if response.status != 200: @@ -411,7 +416,6 @@ def _account_data(cloud): 'cloud': cloud.iot.state, 'prefs': client.prefs.as_dict(), 'google_entities': client.google_user_config['filter'].config, - 'google_domains': list(google_const.DOMAIN_TO_GOOGLE_TYPES), 'alexa_entities': client.alexa_config.should_expose.config, 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), 'remote_domain': remote.instance_domain, @@ -448,3 +452,55 @@ async def websocket_remote_disconnect(hass, connection, msg): await cloud.client.prefs.async_update(remote_enabled=False) await cloud.remote.disconnect() connection.send_result(msg['id'], _account_data(cloud)) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +@websocket_api.websocket_command({ + 'type': 'cloud/google_assistant/entities' +}) +async def google_assistant_list(hass, connection, msg): + """List all google assistant entities.""" + cloud = hass.data[DOMAIN] + entities = google_helpers.async_get_entities( + hass, cloud.client.google_config + ) + + result = [] + + for entity in entities: + result.append({ + 'entity_id': entity.entity_id, + 'traits': [trait.name for trait in entity.traits()], + 'might_2fa': entity.might_2fa(), + }) + + connection.send_result(msg['id'], result) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +@websocket_api.websocket_command({ + 'type': 'cloud/google_assistant/entities/update', + 'entity_id': str, + vol.Optional('should_expose'): bool, + vol.Optional('override_name'): str, + vol.Optional('aliases'): [str], + vol.Optional('disable_2fa'): bool, +}) +async def google_assistant_update(hass, connection, msg): + """List all google assistant entities.""" + cloud = hass.data[DOMAIN] + changes = dict(msg) + changes.pop('type') + changes.pop('id') + + await cloud.client.prefs.async_update_google_entity_config(**changes) + + connection.send_result( + msg['id'], + cloud.client.prefs.google_entity_configs.get(msg['entity_id'])) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 863e3e86da4..982b51133a5 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.12" + "hass-nabucasa==0.13" ], "dependencies": [ "http", diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 0e2abae15b0..0f45f25c49b 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -4,6 +4,8 @@ from ipaddress import ip_address 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, InvalidTrustedNetworks) STORAGE_KEY = DOMAIN @@ -30,6 +32,7 @@ class CloudPreferences: PREF_ENABLE_GOOGLE: True, PREF_ENABLE_REMOTE: False, PREF_GOOGLE_SECURE_DEVICES_PIN: None, + PREF_GOOGLE_ENTITY_CONFIGS: {}, PREF_CLOUDHOOKS: {}, PREF_CLOUD_USER: None, } @@ -39,7 +42,7 @@ class CloudPreferences: async def async_update(self, *, google_enabled=_UNDEF, alexa_enabled=_UNDEF, remote_enabled=_UNDEF, google_secure_devices_pin=_UNDEF, cloudhooks=_UNDEF, - cloud_user=_UNDEF): + cloud_user=_UNDEF, google_entity_configs=_UNDEF): """Update user preferences.""" for key, value in ( (PREF_ENABLE_GOOGLE, google_enabled), @@ -48,6 +51,7 @@ class CloudPreferences: (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), (PREF_CLOUDHOOKS, cloudhooks), (PREF_CLOUD_USER, cloud_user), + (PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs), ): if value is not _UNDEF: self._prefs[key] = value @@ -57,9 +61,48 @@ class CloudPreferences: await self._store.async_save(self._prefs) + async def async_update_google_entity_config( + self, *, entity_id, override_name=_UNDEF, disable_2fa=_UNDEF, + aliases=_UNDEF, should_expose=_UNDEF): + """Update config for a Google entity.""" + entities = self.google_entity_configs + entity = entities.get(entity_id, {}) + + changes = {} + for key, value in ( + (PREF_OVERRIDE_NAME, override_name), + (PREF_DISABLE_2FA, disable_2fa), + (PREF_ALIASES, aliases), + (PREF_SHOULD_EXPOSE, should_expose), + ): + if value is not _UNDEF: + changes[key] = value + + if not changes: + return + + updated_entity = { + **entity, + **changes, + } + + updated_entities = { + **entities, + entity_id: updated_entity, + } + await self.async_update(google_entity_configs=updated_entities) + def as_dict(self): """Return dictionary version.""" - return self._prefs + return { + PREF_ENABLE_ALEXA: self.alexa_enabled, + PREF_ENABLE_GOOGLE: self.google_enabled, + PREF_ENABLE_REMOTE: self.remote_enabled, + PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, + PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs, + PREF_CLOUDHOOKS: self.cloudhooks, + PREF_CLOUD_USER: self.cloud_user, + } @property def remote_enabled(self): @@ -89,6 +132,11 @@ class CloudPreferences: """Return if Google is allowed to unlock locks.""" return self._prefs.get(PREF_GOOGLE_SECURE_DEVICES_PIN) + @property + def google_entity_configs(self): + """Return Google Entity configurations.""" + return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) + @property def cloudhooks(self): """Return the published cloud webhooks.""" diff --git a/homeassistant/components/cloud/utils.py b/homeassistant/components/cloud/utils.py index da1d3809989..1d53681cbea 100644 --- a/homeassistant/components/cloud/utils.py +++ b/homeassistant/components/cloud/utils.py @@ -1,13 +1,25 @@ """Helper functions for cloud components.""" from typing import Any, Dict -from aiohttp import web +from aiohttp import web, payload def aiohttp_serialize_response(response: web.Response) -> Dict[str, Any]: """Serialize an aiohttp response to a dictionary.""" + body = response.body + + if body is None: + pass + elif isinstance(body, payload.StringPayload): + # pylint: disable=protected-access + body = body._value.decode(body.encoding) + elif isinstance(body, bytes): + body = body.decode(response.charset or 'utf-8') + else: + raise ValueError("Unknown payload encoding") + return { 'status': response.status, - 'body': response.text, + 'body': body, 'headers': dict(response.headers), } diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index 384aadd8bf4..3c06bc0c2d7 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -106,7 +106,7 @@ class ComedHourlyPricingSensor(Entity): else: url_string += '?type=currenthouraverage' - with async_timeout.timeout(60, loop=self.loop): + with async_timeout.timeout(60): response = await self.websession.get(url_string) # The API responds with MIME type 'text/html' text = await response.text() diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 3752d5d37bf..0cb76cc8c3b 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -30,7 +30,7 @@ ON_DEMAND = ('zwave',) async def async_setup(hass, config): """Set up the config component.""" - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'config', 'config', 'hass:settings', require_admin=True) async def setup_panel(panel_name): @@ -62,7 +62,7 @@ async def async_setup(hass, config): tasks.append(setup_panel(panel_name)) if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) return True @@ -92,6 +92,10 @@ class BaseEditConfigView(HomeAssistantView): """Set value.""" raise NotImplementedError + def _delete_value(self, hass, data, config_key): + """Delete value.""" + raise NotImplementedError + async def get(self, request, config_key): """Fetch device specific config.""" hass = request.app['hass'] @@ -128,7 +132,27 @@ class BaseEditConfigView(HomeAssistantView): current = await self.read_config(hass) self._write_value(hass, current, config_key, data) - await hass.async_add_job(_write, path, current) + await hass.async_add_executor_job(_write, path, current) + + if self.post_write_hook is not None: + hass.async_create_task(self.post_write_hook(hass)) + + return self.json({ + 'result': 'ok', + }) + + async def delete(self, request, config_key): + """Remove an entry.""" + hass = request.app['hass'] + current = await self.read_config(hass) + value = self._get_value(hass, current, config_key) + path = hass.config.path(self.path) + + if value is None: + return self.json_message('Resource not found', 404) + + self._delete_value(hass, current, config_key) + await hass.async_add_executor_job(_write, path, current) if self.post_write_hook is not None: hass.async_create_task(self.post_write_hook(hass)) @@ -161,6 +185,10 @@ class EditKeyBasedConfigView(BaseEditConfigView): """Set value.""" data.setdefault(config_key, {}).update(new_value) + def _delete_value(self, hass, data, config_key): + """Delete value.""" + return data.pop(config_key) + class EditIdBasedConfigView(BaseEditConfigView): """Configure key based config entries.""" @@ -184,6 +212,13 @@ class EditIdBasedConfigView(BaseEditConfigView): value.update(new_value) + def _delete_value(self, hass, data, config_key): + """Delete value.""" + index = next( + idx for idx, val in enumerate(data) + if val.get(CONF_ID) == config_key) + data.pop(index) + def _read(path): """Read YAML helper.""" diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 8865ff39cea..45e1df5907c 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -6,6 +6,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.exceptions import Unauthorized from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView) +from homeassistant.generated import config_flows async def async_setup(hass): @@ -172,7 +173,7 @@ class ConfigManagerAvailableFlowView(HomeAssistantView): async def get(self, request): """List available flow handlers.""" - return self.json(config_entries.FLOWS) + return self.json(config_flows.FLOWS) class OptionManagerFlowIndexView(FlowManagerIndexView): diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index ce7675c41f4..31abb832f23 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -1,12 +1,22 @@ """Component to interact with Hassbian tools.""" +import voluptuous as vol + from homeassistant.components.http import HomeAssistantView from homeassistant.config import async_check_ha_config_file +from homeassistant.components import websocket_api +from homeassistant.const import ( + CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL +) +from homeassistant.helpers import config_validation as cv +from homeassistant.util import location async def async_setup(hass): """Set up the Hassbian config.""" hass.http.register_view(CheckConfigView) + websocket_api.async_register_command(hass, websocket_update_config) + websocket_api.async_register_command(hass, websocket_detect_config) return True @@ -26,3 +36,62 @@ class CheckConfigView(HomeAssistantView): "result": state, "errors": errors, }) + + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command({ + 'type': 'config/core/update', + vol.Optional('latitude'): cv.latitude, + vol.Optional('longitude'): cv.longitude, + vol.Optional('elevation'): int, + vol.Optional('unit_system'): cv.unit_system, + vol.Optional('location_name'): str, + vol.Optional('time_zone'): cv.time_zone, +}) +async def websocket_update_config(hass, connection, msg): + """Handle update core config command.""" + data = dict(msg) + data.pop('id') + data.pop('type') + + try: + await hass.config.update(**data) + connection.send_result(msg['id']) + except ValueError as err: + connection.send_error( + msg['id'], 'invalid_info', str(err) + ) + + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command({ + 'type': 'config/core/detect', +}) +async def websocket_detect_config(hass, connection, msg): + """Detect core config.""" + session = hass.helpers.aiohttp_client.async_get_clientsession() + location_info = await location.async_detect_location_info(session) + + info = {} + + if location_info is None: + connection.send_result(msg['id'], info) + return + + if location_info.use_metric: + info['unit_system'] = CONF_UNIT_SYSTEM_METRIC + else: + info['unit_system'] = CONF_UNIT_SYSTEM_IMPERIAL + + if location_info.latitude: + info['latitude'] = location_info.latitude + + if location_info.longitude: + info['longitude'] = location_info.longitude + + if location_info.time_zone: + info['time_zone'] = location_info.time_zone + + connection.send_result(msg['id'], info) diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 7ea4e117743..7b1d09827fe 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -81,12 +81,7 @@ class DaikinClimate(ClimateDevice): self._api = api self._list = { ATTR_OPERATION_MODE: list(HA_STATE_TO_DAIKIN), - ATTR_FAN_MODE: list( - map( - str.title, - appliance.daikin_values(HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE]) - ) - ), + ATTR_FAN_MODE: self._api.device.fan_rate, ATTR_SWING_MODE: list( map( str.title, @@ -95,11 +90,14 @@ class DaikinClimate(ClimateDevice): ), } - self._supported_features = (SUPPORT_AWAY_MODE | SUPPORT_ON_OFF + self._supported_features = (SUPPORT_ON_OFF | SUPPORT_OPERATION_MODE | SUPPORT_TARGET_TEMPERATURE) - if self._api.device.support_fan_mode: + if self._api.device.support_away_mode: + self._supported_features |= SUPPORT_AWAY_MODE + + if self._api.device.support_fan_rate: self._supported_features |= SUPPORT_FAN_MODE if self._api.device.support_swing_mode: diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index ab842950e24..bb6db101314 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -1,9 +1,10 @@ { "domain": "daikin", "name": "Daikin", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/daikin", "requirements": [ - "pydaikin==1.4.0" + "pydaikin==1.4.6" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index c4f885f5081..8196acc5cf7 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -26,9 +26,12 @@ async def async_setup_platform( async def async_setup_entry(hass, entry, async_add_entities): """Set up Daikin climate based on config_entry.""" daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id) + sensors = [ATTR_INSIDE_TEMPERATURE] + if daikin_api.device.support_outside_temperature: + sensors.append(ATTR_OUTSIDE_TEMPERATURE) async_add_entities([ DaikinClimateSensor(daikin_api, sensor, hass.config.units) - for sensor in SENSOR_TYPES + for sensor in sensors ]) diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 3106a7e8013..f1a058957fa 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -27,8 +27,7 @@ async def async_setup_entry(hass, entry, async_add_entities): if zones: async_add_entities([ DaikinZoneSwitch(daikin_api, zone_id) - for zone_id, name in enumerate(zones) - if name != '-' + for zone_id, zone in enumerate(zones) if zone != ('-', '0') ]) diff --git a/homeassistant/components/darksky/weather.py b/homeassistant/components/darksky/weather.py index dd945e7b01c..84de690504e 100644 --- a/homeassistant/components/darksky/weather.py +++ b/homeassistant/components/darksky/weather.py @@ -103,6 +103,8 @@ class DarkSkyWeather(WeatherEntity): @property def temperature_unit(self): """Return the unit of measurement.""" + if self._dark_sky.units is None: + return None return TEMP_FAHRENHEIT if 'us' in self._dark_sky.units \ else TEMP_CELSIUS diff --git a/homeassistant/components/deconz/.translations/fr.json b/homeassistant/components/deconz/.translations/fr.json index d18df13701e..3d658ca00b0 100644 --- a/homeassistant/components/deconz/.translations/fr.json +++ b/homeassistant/components/deconz/.translations/fr.json @@ -3,12 +3,21 @@ "abort": { "already_configured": "Ce pont est d\u00e9j\u00e0 configur\u00e9", "no_bridges": "Aucun pont deCONZ n'a \u00e9t\u00e9 d\u00e9couvert", - "one_instance_only": "Le composant prend uniquement en charge une instance deCONZ" + "one_instance_only": "Le composant prend uniquement en charge une instance deCONZ", + "updated_instance": "Instance deCONZ mise \u00e0 jour avec la nouvelle adresse d'h\u00f4te" }, "error": { "no_key": "Impossible d'obtenir une cl\u00e9 d'API" }, "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Autoriser l'importation de capteurs virtuels", + "allow_deconz_groups": "Autoriser l'importation des groupes deCONZ" + }, + "description": "Voulez-vous configurer Home Assistant pour qu'il se connecte \u00e0 la passerelle deCONZ fournie par l'add-on hass.io {addon} ?", + "title": "Passerelle deCONZ Zigbee via l'add-on Hass.io" + }, "init": { "data": { "host": "H\u00f4te", diff --git a/homeassistant/components/deconz/.translations/sv.json b/homeassistant/components/deconz/.translations/sv.json index a5efd2a36d9..17367c49f5b 100644 --- a/homeassistant/components/deconz/.translations/sv.json +++ b/homeassistant/components/deconz/.translations/sv.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "Bryggan \u00e4r redan konfigurerad", "no_bridges": "Inga deCONZ-bryggor uppt\u00e4cktes", - "one_instance_only": "Komponenten st\u00f6djer endast en deCONZ-instans" + "one_instance_only": "Komponenten st\u00f6djer endast en deCONZ-instans", + "updated_instance": "Uppdaterad deCONZ-instans med ny v\u00e4rdadress" }, "error": { "no_key": "Det gick inte att ta emot en API-nyckel" @@ -11,8 +12,10 @@ "step": { "hassio_confirm": { "data": { - "allow_clip_sensor": "Till\u00e5t import av virtuella sensorer" + "allow_clip_sensor": "Till\u00e5t import av virtuella sensorer", + "allow_deconz_groups": "Till\u00e5t import av deCONZ-grupper" }, + "description": "Vill du konfigurera Home Assistant f\u00f6r att ansluta till deCONZ gateway som tillhandah\u00e5lls av hass.io till\u00e4gg {addon}?", "title": "deCONZ Zigbee gateway via Hass.io till\u00e4gg" }, "init": { diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 153e654f3fb..71e03da70b7 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -164,6 +164,7 @@ async def async_unload_entry(hass, config_entry): if not hass.data[DOMAIN]: hass.services.async_remove(DOMAIN, SERVICE_DECONZ) hass.services.async_remove(DOMAIN, SERVICE_DEVICE_REFRESH) + elif gateway.master: await async_populate_options(hass, config_entry) new_master_gateway = next(iter(hass.data[DOMAIN].values())) diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index fbb15abc744..6fe8b4324b3 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -1,6 +1,8 @@ """Support for deCONZ binary sensors.""" +from pydeconz.sensor import Presence, Vibration + from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.const import ATTR_BATTERY_LEVEL +from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -15,7 +17,7 @@ ATTR_VIBRATIONSTRENGTH = 'vibrationstrength' async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Old way of setting up deCONZ binary sensors.""" + """Old way of setting up deCONZ platforms.""" pass @@ -26,12 +28,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def async_add_sensor(sensors): """Add binary sensor from deCONZ.""" - from pydeconz.sensor import DECONZ_BINARY_SENSOR entities = [] for sensor in sensors: - if sensor.type in DECONZ_BINARY_SENSOR and \ + if sensor.BINARY and \ not (not gateway.allow_clip_sensor and sensor.type.startswith('CLIP')): @@ -49,16 +50,11 @@ class DeconzBinarySensor(DeconzDevice, BinarySensorDevice): """Representation of a deCONZ binary sensor.""" @callback - def async_update_callback(self, reason): - """Update the sensor's state. - - If reason is that state is updated, - or reachable has changed or battery has changed. - """ - if reason['state'] or \ - 'reachable' in reason['attr'] or \ - 'battery' in reason['attr'] or \ - 'on' in reason['attr']: + def async_update_callback(self, force_update=False): + """Update the sensor's state.""" + changed = set(self._device.changed_keys) + keys = {'battery', 'on', 'reachable', 'state'} + if force_update or any(key in changed for key in keys): self.async_schedule_update_ha_state() @property @@ -69,26 +65,33 @@ class DeconzBinarySensor(DeconzDevice, BinarySensorDevice): @property def device_class(self): """Return the class of the sensor.""" - return self._device.sensor_class + return self._device.SENSOR_CLASS @property def icon(self): """Return the icon to use in the frontend.""" - return self._device.sensor_icon + return self._device.SENSOR_ICON @property def device_state_attributes(self): """Return the state attributes of the sensor.""" - from pydeconz.sensor import PRESENCE, VIBRATION attr = {} if self._device.battery: attr[ATTR_BATTERY_LEVEL] = self._device.battery + if self._device.on is not None: attr[ATTR_ON] = self._device.on - if self._device.type in PRESENCE and self._device.dark is not None: + + if self._device.secondary_temperature is not None: + attr[ATTR_TEMPERATURE] = self._device.secondary_temperature + + if self._device.type in Presence.ZHATYPE and \ + self._device.dark is not None: attr[ATTR_DARK] = self._device.dark - elif self._device.type in VIBRATION: + + elif self._device.type in Vibration.ZHATYPE: attr[ATTR_ORIENTATION] = self._device.orientation attr[ATTR_TILTANGLE] = self._device.tiltangle attr[ATTR_VIBRATIONSTRENGTH] = self._device.vibrationstrength + return attr diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index c4a021a80c2..cde123f7f08 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -1,4 +1,6 @@ """Support for deCONZ climate devices.""" +from pydeconz.sensor import Thermostat + from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE) @@ -12,6 +14,12 @@ from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Old way of setting up deCONZ platforms.""" + pass + + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ climate devices. @@ -22,12 +30,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def async_add_climate(sensors): """Add climate devices from deCONZ.""" - from pydeconz.sensor import THERMOSTAT entities = [] for sensor in sensors: - if sensor.type in THERMOSTAT and \ + if sensor.type in Thermostat.ZHATYPE and \ not (not gateway.allow_clip_sensor and sensor.type.startswith('CLIP')): @@ -59,7 +66,7 @@ class DeconzThermostat(DeconzDevice, ClimateDevice): @property def is_on(self): """Return true if on.""" - return self._device.on + return self._device.state_on async def async_turn_on(self): """Turn on switch.""" diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index d9065ad2727..cf172ad7991 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -4,13 +4,19 @@ import asyncio import async_timeout import voluptuous as vol +from pydeconz.errors import ResponseError, RequestError +from pydeconz.utils import ( + async_discovery, async_get_api_key, async_get_bridgeid) + from homeassistant import config_entries +from homeassistant.components.ssdp import ATTR_MANUFACTURERURL, ATTR_SERIAL from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from .const import CONF_BRIDGEID, DEFAULT_PORT, DOMAIN +DECONZ_MANUFACTURERURL = 'http://www.dresden-elektronik.de' CONF_SERIAL = 'serial' @@ -54,8 +60,6 @@ class DeconzFlowHandler(config_entries.ConfigFlow): If more than one bridge is found let user choose bridge to link. If no bridge is found allow user to manually input configuration. """ - from pydeconz.utils import async_discovery - if user_input is not None: for bridge in self.bridges: if bridge[CONF_HOST] == user_input[CONF_HOST]: @@ -101,8 +105,6 @@ class DeconzFlowHandler(config_entries.ConfigFlow): async def async_step_link(self, user_input=None): """Attempt to link with the deCONZ bridge.""" - from pydeconz.errors import ResponseError, RequestError - from pydeconz.utils import async_get_api_key errors = {} if user_input is not None: @@ -127,8 +129,6 @@ class DeconzFlowHandler(config_entries.ConfigFlow): async def _create_entry(self): """Create entry for gateway.""" - from pydeconz.utils import async_get_bridgeid - if CONF_BRIDGEID not in self.deconz_config: session = aiohttp_client.async_get_clientsession(self.hass) @@ -151,12 +151,12 @@ class DeconzFlowHandler(config_entries.ConfigFlow): entry.data[CONF_HOST] = host self.hass.config_entries.async_update_entry(entry) - async def async_step_discovery(self, discovery_info): - """Prepare configuration for a discovered deCONZ bridge. + async def async_step_ssdp(self, discovery_info): + """Handle a discovered deCONZ bridge.""" + if discovery_info[ATTR_MANUFACTURERURL] != DECONZ_MANUFACTURERURL: + return self.async_abort(reason='not_deconz_bridge') - This flow is triggered by the discovery component. - """ - bridgeid = discovery_info[CONF_SERIAL] + bridgeid = discovery_info[ATTR_SERIAL] gateway_entries = configured_gateways(self.hass) if bridgeid in gateway_entries: @@ -164,10 +164,17 @@ class DeconzFlowHandler(config_entries.ConfigFlow): await self._update_entry(entry, discovery_info[CONF_HOST]) return self.async_abort(reason='updated_instance') + # pylint: disable=unsupported-assignment-operation + self.context[ATTR_SERIAL] = bridgeid + + if any(bridgeid == flow['context'][ATTR_SERIAL] + for flow in self._async_in_progress()): + return self.async_abort(reason='already_in_progress') + deconz_config = { CONF_HOST: discovery_info[CONF_HOST], CONF_PORT: discovery_info[CONF_PORT], - CONF_BRIDGEID: discovery_info[CONF_SERIAL] + CONF_BRIDGEID: bridgeid } return await self.async_step_import(deconz_config) diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index aa29e8c6b58..a89e7fdd595 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -14,7 +14,7 @@ ZIGBEE_SPEC = ['lumi.curtain'] async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Unsupported way of setting up deCONZ covers.""" + """Old way of setting up deCONZ platforms.""" pass diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 73ac2499cd3..90a5c8a3dde 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -31,7 +31,7 @@ class DeconzDevice(Entity): self.unsub_dispatcher() @callback - def async_update_callback(self, reason): + def async_update_callback(self, force_update=False): """Update the device's state.""" self.async_schedule_update_ha_state() diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 46078ea6648..f5d398fcd2f 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -2,6 +2,9 @@ import asyncio import async_timeout +from pydeconz import DeconzSession, errors +from pydeconz.sensor import Switch + from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.const import CONF_EVENT, CONF_HOST, CONF_ID from homeassistant.core import EventOrigin, callback @@ -126,8 +129,7 @@ class DeconzGateway: def async_connection_status_callback(self, available): """Handle signals of gateway connection status.""" self.available = available - async_dispatcher_send(self.hass, self.event_reachable, - {'state': True, 'attr': 'reachable'}) + async_dispatcher_send(self.hass, self.event_reachable, True) @callback def async_event_new_device(self, device_type): @@ -145,9 +147,8 @@ class DeconzGateway: @callback def async_add_remote(self, sensors): """Set up remote from deCONZ.""" - from pydeconz.sensor import SWITCH as DECONZ_REMOTE for sensor in sensors: - if sensor.type in DECONZ_REMOTE and \ + if sensor.type in Switch.ZHATYPE and \ not (not self.allow_clip_sensor and sensor.type.startswith('CLIP')): self.events.append(DeconzEvent(self.hass, sensor)) @@ -187,8 +188,6 @@ class DeconzGateway: async def get_gateway(hass, config, async_add_device_callback, async_connection_status_callback): """Create a gateway object and verify configuration.""" - from pydeconz import DeconzSession, errors - session = aiohttp_client.async_get_clientsession(hass) deconz = DeconzSession(hass.loop, session, **config, @@ -232,8 +231,8 @@ class DeconzEvent: self._device = None @callback - def async_update_callback(self, reason): + def async_update_callback(self, force_update=False): """Fire the event if reason is that state is updated.""" - if reason['state']: + if 'state' in self._device.changed_keys: data = {CONF_ID: self._id, CONF_EVENT: self._device.state} self._hass.bus.async_fire(self._event, data, EventOrigin.remote) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index c195703c36a..a3328ca8042 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -15,7 +15,7 @@ from .gateway import get_gateway_from_config_entry async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Old way of setting up deCONZ lights and group.""" + """Old way of setting up deCONZ platforms.""" pass diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 22947d40fb1..56ea52b7693 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -1,10 +1,16 @@ { "domain": "deconz", "name": "Deconz", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/deconz", "requirements": [ - "pydeconz==58" + "pydeconz==59" ], + "ssdp": { + "manufacturer": [ + "Royal Philips Electronics" + ] + }, "dependencies": [], "codeowners": [ "@kane610" diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index d2e7f6719e9..c8cfa9674c5 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -9,7 +9,7 @@ from .gateway import get_gateway_from_config_entry async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Old way of setting up deCONZ scenes.""" + """Old way of setting up deCONZ platforms.""" pass diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 9f1e87db4ba..efdb8ad8091 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -1,6 +1,8 @@ """Support for deCONZ sensors.""" +from pydeconz.sensor import LightLevel, Switch + from homeassistant.const import ( - ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY) + ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import slugify @@ -16,7 +18,7 @@ ATTR_EVENT_ID = 'event_id' async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Old way of setting up deCONZ sensors.""" + """Old way of setting up deCONZ platforms.""" pass @@ -27,17 +29,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def async_add_sensor(sensors): """Add sensors from deCONZ.""" - from pydeconz.sensor import ( - DECONZ_SENSOR, SWITCH as DECONZ_REMOTE) entities = [] for sensor in sensors: - if sensor.type in DECONZ_SENSOR and \ + if not sensor.BINARY and \ not (not gateway.allow_clip_sensor and sensor.type.startswith('CLIP')): - if sensor.type in DECONZ_REMOTE: + if sensor.type in Switch.ZHATYPE: if sensor.battery: entities.append(DeconzBattery(sensor, gateway)) @@ -56,16 +56,11 @@ class DeconzSensor(DeconzDevice): """Representation of a deCONZ sensor.""" @callback - def async_update_callback(self, reason): - """Update the sensor's state. - - If reason is that state is updated, - or reachable has changed or battery has changed. - """ - if reason['state'] or \ - 'reachable' in reason['attr'] or \ - 'battery' in reason['attr'] or \ - 'on' in reason['attr']: + def async_update_callback(self, force_update=False): + """Update the sensor's state.""" + changed = set(self._device.changed_keys) + keys = {'battery', 'on', 'reachable', 'state'} + if force_update or any(key in changed for key in keys): self.async_schedule_update_ha_state() @property @@ -76,34 +71,42 @@ class DeconzSensor(DeconzDevice): @property def device_class(self): """Return the class of the sensor.""" - return self._device.sensor_class + return self._device.SENSOR_CLASS @property def icon(self): """Return the icon to use in the frontend.""" - return self._device.sensor_icon + return self._device.SENSOR_ICON @property def unit_of_measurement(self): """Return the unit of measurement of this sensor.""" - return self._device.sensor_unit + return self._device.SENSOR_UNIT @property def device_state_attributes(self): """Return the state attributes of the sensor.""" - from pydeconz.sensor import LIGHTLEVEL attr = {} if self._device.battery: attr[ATTR_BATTERY_LEVEL] = self._device.battery + if self._device.on is not None: attr[ATTR_ON] = self._device.on - if self._device.type in LIGHTLEVEL and self._device.dark is not None: + + if self._device.secondary_temperature is not None: + attr[ATTR_TEMPERATURE] = self._device.secondary_temperature + + if self._device.type in LightLevel.ZHATYPE and \ + self._device.dark is not None: attr[ATTR_DARK] = self._device.dark + if self.unit_of_measurement == 'Watts': attr[ATTR_CURRENT] = self._device.current attr[ATTR_VOLTAGE] = self._device.voltage - if self._device.sensor_class == 'daylight': + + if self._device.SENSOR_CLASS == 'daylight': attr[ATTR_DAYLIGHT] = self._device.daylight + return attr @@ -118,9 +121,11 @@ class DeconzBattery(DeconzDevice): self._unit_of_measurement = "%" @callback - def async_update_callback(self, reason): + def async_update_callback(self, force_update=False): """Update the battery's state, if needed.""" - if 'reachable' in reason['attr'] or 'battery' in reason['attr']: + changed = set(self._device.changed_keys) + keys = {'battery', 'reachable'} + if force_update or any(key in changed for key in keys): self.async_schedule_update_ha_state() @property diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index 16177dbd3cc..d1c70793063 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -34,9 +34,11 @@ }, "abort": { "already_configured": "Bridge is already configured", + "already_in_progress": "Config flow for bridge is already in progress.", "no_bridges": "No deCONZ bridges discovered", - "updated_instance": "Updated deCONZ instance with new host address", - "one_instance_only": "Component only supports one deCONZ instance" + "not_deconz_bridge": "Not a deCONZ bridge", + "one_instance_only": "Component only supports one deCONZ instance", + "updated_instance": "Updated deCONZ instance with new host address" } } } diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index c399f5da128..dd06dba9583 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -10,7 +10,7 @@ from .gateway import get_gateway_from_config_entry async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Old way of setting up deCONZ switches.""" + """Old way of setting up deCONZ platforms.""" pass diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index f52da35dc64..992cb71c07c 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -15,6 +15,7 @@ "mobile_app", "person", "script", + "ssdp", "sun", "system_health", "updater", diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 5a97b43af86..e293632b71e 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -5,7 +5,8 @@ from homeassistant.components.media_player.const import ( SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_SELECT_SOUND_MODE, SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, - SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET) + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP) from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING import homeassistant.util.dt as dt_util @@ -35,7 +36,7 @@ YOUTUBE_PLAYER_SUPPORT = \ MUSIC_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_CLEAR_PLAYLIST | \ - SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | \ + SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | SUPPORT_VOLUME_STEP | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_SELECT_SOUND_MODE @@ -122,6 +123,16 @@ class AbstractDemoPlayer(MediaPlayerDevice): self._volume_muted = mute self.schedule_update_ha_state() + def volume_up(self): + """Increase volume.""" + self._volume_level = min(1.0, self._volume_level + 0.1) + self.schedule_update_ha_state() + + def volume_down(self): + """Decrease volume.""" + self._volume_level = max(0.0, self._volume_level - 0.1) + self.schedule_update_ha_state() + def set_volume_level(self, volume): """Set the volume level, range 0..1.""" self._volume_level = volume diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 60dac103a46..4c67e6fa65d 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -1,78 +1,52 @@ """Provide functionality to keep track of devices.""" import asyncio -from datetime import timedelta -import logging -from typing import Any, List, Sequence, Callable import voluptuous as vol -from homeassistant.setup import async_prepare_setup_platform -from homeassistant.core import callback from homeassistant.loader import bind_hass -from homeassistant.components import group, zone -from homeassistant.components.group import ( - ATTR_ADD_ENTITIES, ATTR_ENTITIES, ATTR_OBJECT_ID, ATTR_VISIBLE, - DOMAIN as DOMAIN_GROUP, SERVICE_SET) -from homeassistant.components.zone.zone import async_active_zone -from homeassistant.config import load_yaml_config_file, async_log_exception -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.components import group +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import GPSType, ConfigType, HomeAssistantType -from homeassistant import util -from homeassistant.util.async_ import run_coroutine_threadsafe -import homeassistant.util.dt as dt_util -from homeassistant.util.yaml import dump from homeassistant.helpers.event import async_track_utc_time_change -from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_GPS_ACCURACY, ATTR_ICON, ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_NAME, CONF_ICON, CONF_MAC, CONF_NAME, - DEVICE_DEFAULT_NAME, STATE_NOT_HOME, STATE_HOME) +from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME -_LOGGER = logging.getLogger(__name__) +from . import legacy, setup +from .config_entry import ( # noqa # pylint: disable=unused-import + async_setup_entry, async_unload_entry +) +from .legacy import DeviceScanner # noqa # pylint: disable=unused-import +from .const import ( + ATTR_ATTRIBUTES, + ATTR_BATTERY, + ATTR_CONSIDER_HOME, + ATTR_DEV_ID, + ATTR_GPS, + ATTR_HOST_NAME, + ATTR_LOCATION_NAME, + ATTR_MAC, + ATTR_SOURCE_TYPE, + CONF_AWAY_HIDE, + CONF_CONSIDER_HOME, + CONF_NEW_DEVICE_DEFAULTS, + CONF_SCAN_INTERVAL, + CONF_TRACK_NEW, + DEFAULT_AWAY_HIDE, + DEFAULT_CONSIDER_HOME, + DEFAULT_TRACK_NEW, + DOMAIN, + PLATFORM_TYPE_LEGACY, + SOURCE_TYPE_BLUETOOTH_LE, + SOURCE_TYPE_BLUETOOTH, + SOURCE_TYPE_GPS, + SOURCE_TYPE_ROUTER, +) -DOMAIN = 'device_tracker' -GROUP_NAME_ALL_DEVICES = 'all devices' ENTITY_ID_ALL_DEVICES = group.ENTITY_ID_FORMAT.format('all_devices') -ENTITY_ID_FORMAT = DOMAIN + '.{}' - -YAML_DEVICES = 'known_devices.yaml' - -CONF_TRACK_NEW = 'track_new_devices' -DEFAULT_TRACK_NEW = True -CONF_NEW_DEVICE_DEFAULTS = 'new_device_defaults' - -CONF_CONSIDER_HOME = 'consider_home' -DEFAULT_CONSIDER_HOME = timedelta(seconds=180) - -CONF_SCAN_INTERVAL = 'interval_seconds' -DEFAULT_SCAN_INTERVAL = timedelta(seconds=12) - -CONF_AWAY_HIDE = 'hide_if_away' -DEFAULT_AWAY_HIDE = False - -EVENT_NEW_DEVICE = 'device_tracker_new_device' - SERVICE_SEE = 'see' -ATTR_ATTRIBUTES = 'attributes' -ATTR_BATTERY = 'battery' -ATTR_DEV_ID = 'dev_id' -ATTR_GPS = 'gps' -ATTR_HOST_NAME = 'host_name' -ATTR_LOCATION_NAME = 'location_name' -ATTR_MAC = 'mac' -ATTR_SOURCE_TYPE = 'source_type' -ATTR_CONSIDER_HOME = 'consider_home' - -SOURCE_TYPE_GPS = 'gps' -SOURCE_TYPE_ROUTER = 'router' -SOURCE_TYPE_BLUETOOTH = 'bluetooth' -SOURCE_TYPE_BLUETOOTH_LE = 'bluetooth_le' SOURCE_TYPES = (SOURCE_TYPE_GPS, SOURCE_TYPE_ROUTER, SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_BLUETOOTH_LE) @@ -136,75 +110,29 @@ def see(hass: HomeAssistantType, mac: str = None, dev_id: str = None, async def async_setup(hass: HomeAssistantType, config: ConfigType): """Set up the device tracker.""" - yaml_path = hass.config.path(YAML_DEVICES) + tracker = await legacy.get_tracker(hass, config) - conf = config.get(DOMAIN, []) - conf = conf[0] if conf else {} - consider_home = conf.get(CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME) + legacy_platforms = await setup.async_extract_config(hass, config) - defaults = conf.get(CONF_NEW_DEVICE_DEFAULTS, {}) - track_new = conf.get(CONF_TRACK_NEW) - if track_new is None: - track_new = defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) + setup_tasks = [ + legacy_platform.async_setup_legacy(hass, tracker) + for legacy_platform in legacy_platforms + ] - devices = await async_load_config(yaml_path, hass, consider_home) - tracker = DeviceTracker( - hass, consider_home, track_new, defaults, devices) - - async def async_setup_platform(p_type, p_config, disc_info=None): - """Set up a device tracker platform.""" - platform = await async_prepare_setup_platform( - hass, config, DOMAIN, p_type) - if platform is None: - return - - _LOGGER.info("Setting up %s.%s", DOMAIN, p_type) - try: - scanner = None - setup = None - if hasattr(platform, 'async_get_scanner'): - scanner = await platform.async_get_scanner( - hass, {DOMAIN: p_config}) - elif hasattr(platform, 'get_scanner'): - scanner = await hass.async_add_job( - platform.get_scanner, hass, {DOMAIN: p_config}) - elif hasattr(platform, 'async_setup_scanner'): - setup = await platform.async_setup_scanner( - hass, p_config, tracker.async_see, disc_info) - elif hasattr(platform, 'setup_scanner'): - setup = await hass.async_add_job( - platform.setup_scanner, hass, p_config, tracker.see, - disc_info) - elif hasattr(platform, 'async_setup_entry'): - setup = await platform.async_setup_entry( - hass, p_config, tracker.async_see) - else: - raise HomeAssistantError("Invalid device_tracker platform.") - - if scanner: - async_setup_scanner_platform( - hass, p_config, scanner, tracker.async_see, p_type) - return - - if not setup: - _LOGGER.error("Error setting up platform %s", p_type) - return - - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error setting up platform %s", p_type) - - hass.data[DOMAIN] = async_setup_platform - - setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config - in config_per_platform(config, DOMAIN)] if setup_tasks: - await asyncio.wait(setup_tasks, loop=hass.loop) + await asyncio.wait(setup_tasks) tracker.async_setup_group() - async def async_platform_discovered(platform, info): + async def async_platform_discovered(p_type, info): """Load a platform.""" - await async_setup_platform(platform, {}, disc_info=info) + platform = await setup.async_create_platform_type( + hass, config, p_type, {}) + + if platform is None or platform.type != PLATFORM_TYPE_LEGACY: + return + + await platform.async_setup_legacy(hass, tracker, info) discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) @@ -226,537 +154,3 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): # restore await tracker.async_setup_tracked_device() return True - - -async def async_setup_entry(hass, entry): - """Set up an entry.""" - await hass.data[DOMAIN](entry.domain, entry) - return True - - -class DeviceTracker: - """Representation of a device tracker.""" - - def __init__(self, hass: HomeAssistantType, consider_home: timedelta, - track_new: bool, defaults: dict, - devices: Sequence) -> None: - """Initialize a device tracker.""" - self.hass = hass - self.devices = {dev.dev_id: dev for dev in devices} - self.mac_to_dev = {dev.mac: dev for dev in devices if dev.mac} - self.consider_home = consider_home - self.track_new = track_new if track_new is not None \ - else defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) - self.defaults = defaults - self.group = None - self._is_updating = asyncio.Lock(loop=hass.loop) - - for dev in devices: - if self.devices[dev.dev_id] is not dev: - _LOGGER.warning('Duplicate device IDs detected %s', dev.dev_id) - if dev.mac and self.mac_to_dev[dev.mac] is not dev: - _LOGGER.warning('Duplicate device MAC addresses detected %s', - dev.mac) - - def see(self, mac: str = None, dev_id: str = None, host_name: str = None, - location_name: str = None, gps: GPSType = None, - gps_accuracy: int = None, battery: int = None, - attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, - picture: str = None, icon: str = None, - consider_home: timedelta = None): - """Notify the device tracker that you see a device.""" - self.hass.add_job( - self.async_see(mac, dev_id, host_name, location_name, gps, - gps_accuracy, battery, attributes, source_type, - picture, icon, consider_home) - ) - - async def async_see( - self, mac: str = None, dev_id: str = None, host_name: str = None, - location_name: str = None, gps: GPSType = None, - gps_accuracy: int = None, battery: int = None, - attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, - picture: str = None, icon: str = None, - consider_home: timedelta = None): - """Notify the device tracker that you see a device. - - This method is a coroutine. - """ - if mac is None and dev_id is None: - raise HomeAssistantError('Neither mac or device id passed in') - if mac is not None: - mac = str(mac).upper() - device = self.mac_to_dev.get(mac) - if not device: - dev_id = util.slugify(host_name or '') or util.slugify(mac) - else: - dev_id = cv.slug(str(dev_id).lower()) - device = self.devices.get(dev_id) - - if device: - await device.async_seen( - host_name, location_name, gps, gps_accuracy, battery, - attributes, source_type, consider_home) - if device.track: - await device.async_update_ha_state() - return - - # If no device can be found, create it - dev_id = util.ensure_unique_string(dev_id, self.devices.keys()) - device = Device( - self.hass, consider_home or self.consider_home, self.track_new, - dev_id, mac, (host_name or dev_id).replace('_', ' '), - picture=picture, icon=icon, - hide_if_away=self.defaults.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) - self.devices[dev_id] = device - if mac is not None: - self.mac_to_dev[mac] = device - - await device.async_seen( - host_name, location_name, gps, gps_accuracy, battery, attributes, - source_type) - - if device.track: - await device.async_update_ha_state() - - # During init, we ignore the group - if self.group and self.track_new: - self.hass.async_create_task( - self.hass.async_call( - DOMAIN_GROUP, SERVICE_SET, { - ATTR_OBJECT_ID: util.slugify(GROUP_NAME_ALL_DEVICES), - ATTR_VISIBLE: False, - ATTR_NAME: GROUP_NAME_ALL_DEVICES, - ATTR_ADD_ENTITIES: [device.entity_id]})) - - self.hass.bus.async_fire(EVENT_NEW_DEVICE, { - ATTR_ENTITY_ID: device.entity_id, - ATTR_HOST_NAME: device.host_name, - ATTR_MAC: device.mac, - }) - - # update known_devices.yaml - self.hass.async_create_task( - self.async_update_config( - self.hass.config.path(YAML_DEVICES), dev_id, device) - ) - - async def async_update_config(self, path, dev_id, device): - """Add device to YAML configuration file. - - This method is a coroutine. - """ - async with self._is_updating: - await self.hass.async_add_executor_job( - update_config, self.hass.config.path(YAML_DEVICES), - dev_id, device) - - @callback - def async_setup_group(self): - """Initialize group for all tracked devices. - - This method must be run in the event loop. - """ - entity_ids = [dev.entity_id for dev in self.devices.values() - if dev.track] - - self.hass.async_create_task( - self.hass.services.async_call( - DOMAIN_GROUP, SERVICE_SET, { - ATTR_OBJECT_ID: util.slugify(GROUP_NAME_ALL_DEVICES), - ATTR_VISIBLE: False, - ATTR_NAME: GROUP_NAME_ALL_DEVICES, - ATTR_ENTITIES: entity_ids})) - - @callback - def async_update_stale(self, now: dt_util.dt.datetime): - """Update stale devices. - - This method must be run in the event loop. - """ - for device in self.devices.values(): - if (device.track and device.last_update_home) and \ - device.stale(now): - self.hass.async_create_task(device.async_update_ha_state(True)) - - async def async_setup_tracked_device(self): - """Set up all not exists tracked devices. - - This method is a coroutine. - """ - async def async_init_single_device(dev): - """Init a single device_tracker entity.""" - await dev.async_added_to_hass() - await dev.async_update_ha_state() - - tasks = [] - for device in self.devices.values(): - if device.track and not device.last_seen: - tasks.append(self.hass.async_create_task( - async_init_single_device(device))) - - if tasks: - await asyncio.wait(tasks, loop=self.hass.loop) - - -class Device(RestoreEntity): - """Represent a tracked device.""" - - host_name = None # type: str - location_name = None # type: str - gps = None # type: GPSType - gps_accuracy = 0 # type: int - last_seen = None # type: dt_util.dt.datetime - consider_home = None # type: dt_util.dt.timedelta - battery = None # type: int - attributes = None # type: dict - icon = None # type: str - - # Track if the last update of this device was HOME. - last_update_home = False - _state = STATE_NOT_HOME - - def __init__(self, hass: HomeAssistantType, consider_home: timedelta, - track: bool, dev_id: str, mac: str, name: str = None, - picture: str = None, gravatar: str = None, icon: str = None, - hide_if_away: bool = False) -> None: - """Initialize a device.""" - self.hass = hass - self.entity_id = ENTITY_ID_FORMAT.format(dev_id) - - # Timedelta object how long we consider a device home if it is not - # detected anymore. - self.consider_home = consider_home - - # Device ID - self.dev_id = dev_id - self.mac = mac - - # If we should track this device - self.track = track - - # Configured name - self.config_name = name - - # Configured picture - if gravatar is not None: - self.config_picture = get_gravatar_for_email(gravatar) - else: - self.config_picture = picture - - self.icon = icon - - self.away_hide = hide_if_away - - self.source_type = None - - self._attributes = {} - - @property - def name(self): - """Return the name of the entity.""" - return self.config_name or self.host_name or DEVICE_DEFAULT_NAME - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def entity_picture(self): - """Return the picture of the device.""" - return self.config_picture - - @property - def state_attributes(self): - """Return the device state attributes.""" - attr = { - ATTR_SOURCE_TYPE: self.source_type - } - - if self.gps: - attr[ATTR_LATITUDE] = self.gps[0] - attr[ATTR_LONGITUDE] = self.gps[1] - attr[ATTR_GPS_ACCURACY] = self.gps_accuracy - - if self.battery: - attr[ATTR_BATTERY] = self.battery - - return attr - - @property - def device_state_attributes(self): - """Return device state attributes.""" - return self._attributes - - @property - def hidden(self): - """If device should be hidden.""" - return self.away_hide and self.state != STATE_HOME - - async def async_seen( - self, host_name: str = None, location_name: str = None, - gps: GPSType = None, gps_accuracy=0, battery: int = None, - attributes: dict = None, - source_type: str = SOURCE_TYPE_GPS, - consider_home: timedelta = None): - """Mark the device as seen.""" - self.source_type = source_type - self.last_seen = dt_util.utcnow() - self.host_name = host_name - self.location_name = location_name - self.consider_home = consider_home or self.consider_home - - if battery: - self.battery = battery - if attributes: - self._attributes.update(attributes) - - self.gps = None - - if gps is not None: - try: - self.gps = float(gps[0]), float(gps[1]) - self.gps_accuracy = gps_accuracy or 0 - except (ValueError, TypeError, IndexError): - self.gps = None - self.gps_accuracy = 0 - _LOGGER.warning( - "Could not parse gps value for %s: %s", self.dev_id, gps) - - # pylint: disable=not-an-iterable - await self.async_update() - - def stale(self, now: dt_util.dt.datetime = None): - """Return if device state is stale. - - Async friendly. - """ - return self.last_seen is None or \ - (now or dt_util.utcnow()) - self.last_seen > self.consider_home - - def mark_stale(self): - """Mark the device state as stale.""" - self._state = STATE_NOT_HOME - self.gps = None - self.last_update_home = False - - async def async_update(self): - """Update state of entity. - - This method is a coroutine. - """ - if not self.last_seen: - return - if self.location_name: - self._state = self.location_name - elif self.gps is not None and self.source_type == SOURCE_TYPE_GPS: - zone_state = async_active_zone( - self.hass, self.gps[0], self.gps[1], self.gps_accuracy) - if zone_state is None: - self._state = STATE_NOT_HOME - elif zone_state.entity_id == zone.ENTITY_ID_HOME: - self._state = STATE_HOME - else: - self._state = zone_state.name - elif self.stale(): - self.mark_stale() - else: - self._state = STATE_HOME - self.last_update_home = True - - async def async_added_to_hass(self): - """Add an entity.""" - await super().async_added_to_hass() - state = await self.async_get_last_state() - if not state: - return - self._state = state.state - self.last_update_home = (state.state == STATE_HOME) - self.last_seen = dt_util.utcnow() - - for attr, var in ( - (ATTR_SOURCE_TYPE, 'source_type'), - (ATTR_GPS_ACCURACY, 'gps_accuracy'), - (ATTR_BATTERY, 'battery'), - ): - if attr in state.attributes: - setattr(self, var, state.attributes[attr]) - - if ATTR_LONGITUDE in state.attributes: - self.gps = (state.attributes[ATTR_LATITUDE], - state.attributes[ATTR_LONGITUDE]) - - -class DeviceScanner: - """Device scanner object.""" - - hass = None # type: HomeAssistantType - - def scan_devices(self) -> List[str]: - """Scan for devices.""" - raise NotImplementedError() - - def async_scan_devices(self) -> Any: - """Scan for devices. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.scan_devices) - - def get_device_name(self, device: str) -> str: - """Get the name of a device.""" - raise NotImplementedError() - - def async_get_device_name(self, device: str) -> Any: - """Get the name of a device. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.get_device_name, device) - - def get_extra_attributes(self, device: str) -> dict: - """Get the extra attributes of a device.""" - raise NotImplementedError() - - def async_get_extra_attributes(self, device: str) -> Any: - """Get the extra attributes of a device. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.get_extra_attributes, device) - - -def load_config(path: str, hass: HomeAssistantType, consider_home: timedelta): - """Load devices from YAML configuration file.""" - return run_coroutine_threadsafe( - async_load_config(path, hass, consider_home), hass.loop).result() - - -async def async_load_config(path: str, hass: HomeAssistantType, - consider_home: timedelta): - """Load devices from YAML configuration file. - - This method is a coroutine. - """ - dev_schema = vol.Schema({ - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_ICON, default=None): vol.Any(None, cv.icon), - vol.Optional('track', default=False): cv.boolean, - vol.Optional(CONF_MAC, default=None): - vol.Any(None, vol.All(cv.string, vol.Upper)), - vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean, - vol.Optional('gravatar', default=None): vol.Any(None, cv.string), - vol.Optional('picture', default=None): vol.Any(None, cv.string), - vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All( - cv.time_period, cv.positive_timedelta), - }) - try: - result = [] - try: - devices = await hass.async_add_job( - load_yaml_config_file, path) - except HomeAssistantError as err: - _LOGGER.error("Unable to load %s: %s", path, str(err)) - return [] - - for dev_id, device in devices.items(): - # Deprecated option. We just ignore it to avoid breaking change - device.pop('vendor', None) - try: - device = dev_schema(device) - device['dev_id'] = cv.slugify(dev_id) - except vol.Invalid as exp: - async_log_exception(exp, dev_id, devices, hass) - else: - result.append(Device(hass, **device)) - return result - except (HomeAssistantError, FileNotFoundError): - # When YAML file could not be loaded/did not contain a dict - return [] - - -@callback -def async_setup_scanner_platform(hass: HomeAssistantType, config: ConfigType, - scanner: Any, async_see_device: Callable, - platform: str): - """Set up the connect scanner-based platform to device tracker. - - This method must be run in the event loop. - """ - interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - update_lock = asyncio.Lock(loop=hass.loop) - scanner.hass = hass - - # Initial scan of each mac we also tell about host name for config - seen = set() # type: Any - - async def async_device_tracker_scan(now: dt_util.dt.datetime): - """Handle interval matches.""" - if update_lock.locked(): - _LOGGER.warning( - "Updating device list from %s took longer than the scheduled " - "scan interval %s", platform, interval) - return - - async with update_lock: - found_devices = await scanner.async_scan_devices() - - for mac in found_devices: - if mac in seen: - host_name = None - else: - host_name = await scanner.async_get_device_name(mac) - seen.add(mac) - - try: - extra_attributes = \ - await scanner.async_get_extra_attributes(mac) - except NotImplementedError: - extra_attributes = dict() - - kwargs = { - 'mac': mac, - 'host_name': host_name, - 'source_type': SOURCE_TYPE_ROUTER, - 'attributes': { - 'scanner': scanner.__class__.__name__, - **extra_attributes - } - } - - zone_home = hass.states.get(zone.ENTITY_ID_HOME) - if zone_home: - kwargs['gps'] = [zone_home.attributes[ATTR_LATITUDE], - zone_home.attributes[ATTR_LONGITUDE]] - kwargs['gps_accuracy'] = 0 - - hass.async_create_task(async_see_device(**kwargs)) - - async_track_time_interval(hass, async_device_tracker_scan, interval) - hass.async_create_task(async_device_tracker_scan(None)) - - -def update_config(path: str, dev_id: str, device: Device): - """Add device to YAML configuration file.""" - with open(path, 'a') as out: - device = {device.dev_id: { - ATTR_NAME: device.name, - ATTR_MAC: device.mac, - ATTR_ICON: device.icon, - 'picture': device.config_picture, - 'track': device.track, - CONF_AWAY_HIDE: device.away_hide, - }} - out.write('\n') - out.write(dump(device)) - - -def get_gravatar_for_email(email: str): - """Return an 80px Gravatar for the given email address. - - Async friendly. - """ - import hashlib - url = 'https://www.gravatar.com/avatar/{}.jpg?s=80&d=wavatar' - return url.format(hashlib.md5(email.encode('utf-8').lower()).hexdigest()) diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py new file mode 100644 index 00000000000..59f6c0c49c1 --- /dev/null +++ b/homeassistant/components/device_tracker/config_entry.py @@ -0,0 +1,114 @@ +"""Code to set up a device tracker platform using a config entry.""" +from typing import Optional + +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.const import ( + STATE_NOT_HOME, + STATE_HOME, + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_BATTERY_LEVEL, +) +from homeassistant.components import zone + +from .const import ( + ATTR_SOURCE_TYPE, + DOMAIN, + LOGGER, +) + + +async def async_setup_entry(hass, entry): + """Set up an entry.""" + component = hass.data.get(DOMAIN) # type: Optional[EntityComponent] + + if component is None: + component = hass.data[DOMAIN] = EntityComponent( + LOGGER, DOMAIN, hass + ) + + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload an entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + +class DeviceTrackerEntity(Entity): + """Represent a tracked device.""" + + @property + def battery_level(self): + """Return the battery level of the device. + + Percentage from 0-100. + """ + return None + + @property + def location_accuracy(self): + """Return the location accuracy of the device. + + Value in meters. + """ + return 0 + + @property + def location_name(self) -> str: + """Return a location name for the current location of the device.""" + return None + + @property + def latitude(self) -> float: + """Return latitude value of the device.""" + return NotImplementedError + + @property + def longitude(self) -> float: + """Return longitude value of the device.""" + return NotImplementedError + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + raise NotImplementedError + + @property + def state(self): + """Return the state of the device.""" + if self.location_name: + return self.location_name + + if self.latitude is not None: + zone_state = zone.async_active_zone( + self.hass, self.latitude, self.longitude, + self.location_accuracy) + if zone_state is None: + state = STATE_NOT_HOME + elif zone_state.entity_id == zone.ENTITY_ID_HOME: + state = STATE_HOME + else: + state = zone_state.name + return state + + return None + + @property + def state_attributes(self): + """Return the device state attributes.""" + attr = { + ATTR_SOURCE_TYPE: self.source_type + } + + if self.latitude is not None: + attr[ATTR_LATITUDE] = self.latitude + attr[ATTR_LONGITUDE] = self.longitude + attr[ATTR_GPS_ACCURACY] = self.location_accuracy + + if self.battery_level: + attr[ATTR_BATTERY_LEVEL] = self.battery_level + + return attr diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py new file mode 100644 index 00000000000..18ec486e693 --- /dev/null +++ b/homeassistant/components/device_tracker/const.py @@ -0,0 +1,40 @@ +"""Device tracker constants.""" +from datetime import timedelta +import logging + +LOGGER = logging.getLogger(__package__) + +DOMAIN = 'device_tracker' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +PLATFORM_TYPE_LEGACY = 'legacy' +PLATFORM_TYPE_ENTITY = 'entity_platform' + +SOURCE_TYPE_GPS = 'gps' +SOURCE_TYPE_ROUTER = 'router' +SOURCE_TYPE_BLUETOOTH = 'bluetooth' +SOURCE_TYPE_BLUETOOTH_LE = 'bluetooth_le' + +CONF_SCAN_INTERVAL = 'interval_seconds' +SCAN_INTERVAL = timedelta(seconds=12) + +CONF_TRACK_NEW = 'track_new_devices' +DEFAULT_TRACK_NEW = True + +CONF_AWAY_HIDE = 'hide_if_away' +DEFAULT_AWAY_HIDE = False + +CONF_CONSIDER_HOME = 'consider_home' +DEFAULT_CONSIDER_HOME = timedelta(seconds=180) + +CONF_NEW_DEVICE_DEFAULTS = 'new_device_defaults' + +ATTR_ATTRIBUTES = 'attributes' +ATTR_BATTERY = 'battery' +ATTR_DEV_ID = 'dev_id' +ATTR_GPS = 'gps' +ATTR_HOST_NAME = 'host_name' +ATTR_LOCATION_NAME = 'location_name' +ATTR_MAC = 'mac' +ATTR_SOURCE_TYPE = 'source_type' +ATTR_CONSIDER_HOME = 'consider_home' diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py new file mode 100644 index 00000000000..1fdd8077728 --- /dev/null +++ b/homeassistant/components/device_tracker/legacy.py @@ -0,0 +1,526 @@ +"""Legacy device tracker classes.""" +import asyncio +from datetime import timedelta +from typing import Any, List, Sequence + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components import zone +from homeassistant.components.group import ( + ATTR_ADD_ENTITIES, ATTR_ENTITIES, ATTR_OBJECT_ID, ATTR_VISIBLE, + DOMAIN as DOMAIN_GROUP, SERVICE_SET) +from homeassistant.components.zone import async_active_zone +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.restore_state import RestoreEntity +from homeassistant.helpers.typing import GPSType, HomeAssistantType +from homeassistant import util +import homeassistant.util.dt as dt_util +from homeassistant.util.yaml import dump + +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_GPS_ACCURACY, ATTR_ICON, ATTR_LATITUDE, + ATTR_LONGITUDE, ATTR_NAME, CONF_ICON, CONF_MAC, CONF_NAME, + DEVICE_DEFAULT_NAME, STATE_NOT_HOME, STATE_HOME) + +from .const import ( + ATTR_BATTERY, + ATTR_HOST_NAME, + ATTR_MAC, + ATTR_SOURCE_TYPE, + CONF_AWAY_HIDE, + CONF_CONSIDER_HOME, + CONF_NEW_DEVICE_DEFAULTS, + CONF_TRACK_NEW, + DEFAULT_AWAY_HIDE, + DEFAULT_CONSIDER_HOME, + DEFAULT_TRACK_NEW, + DOMAIN, + ENTITY_ID_FORMAT, + LOGGER, + SOURCE_TYPE_GPS, +) + +YAML_DEVICES = 'known_devices.yaml' +GROUP_NAME_ALL_DEVICES = 'all devices' +EVENT_NEW_DEVICE = 'device_tracker_new_device' + + +async def get_tracker(hass, config): + """Create a tracker.""" + yaml_path = hass.config.path(YAML_DEVICES) + + conf = config.get(DOMAIN, []) + conf = conf[0] if conf else {} + consider_home = conf.get(CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME) + + defaults = conf.get(CONF_NEW_DEVICE_DEFAULTS, {}) + track_new = conf.get(CONF_TRACK_NEW) + if track_new is None: + track_new = defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) + + devices = await async_load_config(yaml_path, hass, consider_home) + tracker = DeviceTracker( + hass, consider_home, track_new, defaults, devices) + return tracker + + +class DeviceTracker: + """Representation of a device tracker.""" + + def __init__(self, hass: HomeAssistantType, consider_home: timedelta, + track_new: bool, defaults: dict, + devices: Sequence) -> None: + """Initialize a device tracker.""" + self.hass = hass + self.devices = {dev.dev_id: dev for dev in devices} + self.mac_to_dev = {dev.mac: dev for dev in devices if dev.mac} + self.consider_home = consider_home + self.track_new = track_new if track_new is not None \ + else defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) + self.defaults = defaults + self.group = None + self._is_updating = asyncio.Lock() + + for dev in devices: + if self.devices[dev.dev_id] is not dev: + LOGGER.warning('Duplicate device IDs detected %s', dev.dev_id) + if dev.mac and self.mac_to_dev[dev.mac] is not dev: + LOGGER.warning('Duplicate device MAC addresses detected %s', + dev.mac) + + def see(self, mac: str = None, dev_id: str = None, host_name: str = None, + location_name: str = None, gps: GPSType = None, + gps_accuracy: int = None, battery: int = None, + attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, + picture: str = None, icon: str = None, + consider_home: timedelta = None): + """Notify the device tracker that you see a device.""" + self.hass.add_job( + self.async_see(mac, dev_id, host_name, location_name, gps, + gps_accuracy, battery, attributes, source_type, + picture, icon, consider_home) + ) + + async def async_see( + self, mac: str = None, dev_id: str = None, host_name: str = None, + location_name: str = None, gps: GPSType = None, + gps_accuracy: int = None, battery: int = None, + attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, + picture: str = None, icon: str = None, + consider_home: timedelta = None): + """Notify the device tracker that you see a device. + + This method is a coroutine. + """ + if mac is None and dev_id is None: + raise HomeAssistantError('Neither mac or device id passed in') + if mac is not None: + mac = str(mac).upper() + device = self.mac_to_dev.get(mac) + if not device: + dev_id = util.slugify(host_name or '') or util.slugify(mac) + else: + dev_id = cv.slug(str(dev_id).lower()) + device = self.devices.get(dev_id) + + if device: + await device.async_seen( + host_name, location_name, gps, gps_accuracy, battery, + attributes, source_type, consider_home) + if device.track: + await device.async_update_ha_state() + return + + # If no device can be found, create it + dev_id = util.ensure_unique_string(dev_id, self.devices.keys()) + device = Device( + self.hass, consider_home or self.consider_home, self.track_new, + dev_id, mac, (host_name or dev_id).replace('_', ' '), + picture=picture, icon=icon, + hide_if_away=self.defaults.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) + self.devices[dev_id] = device + if mac is not None: + self.mac_to_dev[mac] = device + + await device.async_seen( + host_name, location_name, gps, gps_accuracy, battery, attributes, + source_type) + + if device.track: + await device.async_update_ha_state() + + # During init, we ignore the group + if self.group and self.track_new: + self.hass.async_create_task( + self.hass.async_call( + DOMAIN_GROUP, SERVICE_SET, { + ATTR_OBJECT_ID: util.slugify(GROUP_NAME_ALL_DEVICES), + ATTR_VISIBLE: False, + ATTR_NAME: GROUP_NAME_ALL_DEVICES, + ATTR_ADD_ENTITIES: [device.entity_id]})) + + self.hass.bus.async_fire(EVENT_NEW_DEVICE, { + ATTR_ENTITY_ID: device.entity_id, + ATTR_HOST_NAME: device.host_name, + ATTR_MAC: device.mac, + }) + + # update known_devices.yaml + self.hass.async_create_task( + self.async_update_config( + self.hass.config.path(YAML_DEVICES), dev_id, device) + ) + + async def async_update_config(self, path, dev_id, device): + """Add device to YAML configuration file. + + This method is a coroutine. + """ + async with self._is_updating: + await self.hass.async_add_executor_job( + update_config, self.hass.config.path(YAML_DEVICES), + dev_id, device) + + @callback + def async_setup_group(self): + """Initialize group for all tracked devices. + + This method must be run in the event loop. + """ + entity_ids = [dev.entity_id for dev in self.devices.values() + if dev.track] + + self.hass.async_create_task( + self.hass.services.async_call( + DOMAIN_GROUP, SERVICE_SET, { + ATTR_OBJECT_ID: util.slugify(GROUP_NAME_ALL_DEVICES), + ATTR_VISIBLE: False, + ATTR_NAME: GROUP_NAME_ALL_DEVICES, + ATTR_ENTITIES: entity_ids})) + + @callback + def async_update_stale(self, now: dt_util.dt.datetime): + """Update stale devices. + + This method must be run in the event loop. + """ + for device in self.devices.values(): + if (device.track and device.last_update_home) and \ + device.stale(now): + self.hass.async_create_task(device.async_update_ha_state(True)) + + async def async_setup_tracked_device(self): + """Set up all not exists tracked devices. + + This method is a coroutine. + """ + async def async_init_single_device(dev): + """Init a single device_tracker entity.""" + await dev.async_added_to_hass() + await dev.async_update_ha_state() + + tasks = [] + for device in self.devices.values(): + if device.track and not device.last_seen: + tasks.append(self.hass.async_create_task( + async_init_single_device(device))) + + if tasks: + await asyncio.wait(tasks) + + +class Device(RestoreEntity): + """Represent a tracked device.""" + + host_name = None # type: str + location_name = None # type: str + gps = None # type: GPSType + gps_accuracy = 0 # type: int + last_seen = None # type: dt_util.dt.datetime + consider_home = None # type: dt_util.dt.timedelta + battery = None # type: int + attributes = None # type: dict + icon = None # type: str + + # Track if the last update of this device was HOME. + last_update_home = False + _state = STATE_NOT_HOME + + def __init__(self, hass: HomeAssistantType, consider_home: timedelta, + track: bool, dev_id: str, mac: str, name: str = None, + picture: str = None, gravatar: str = None, icon: str = None, + hide_if_away: bool = False) -> None: + """Initialize a device.""" + self.hass = hass + self.entity_id = ENTITY_ID_FORMAT.format(dev_id) + + # Timedelta object how long we consider a device home if it is not + # detected anymore. + self.consider_home = consider_home + + # Device ID + self.dev_id = dev_id + self.mac = mac + + # If we should track this device + self.track = track + + # Configured name + self.config_name = name + + # Configured picture + if gravatar is not None: + self.config_picture = get_gravatar_for_email(gravatar) + else: + self.config_picture = picture + + self.icon = icon + + self.away_hide = hide_if_away + + self.source_type = None + + self._attributes = {} + + @property + def name(self): + """Return the name of the entity.""" + return self.config_name or self.host_name or DEVICE_DEFAULT_NAME + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def entity_picture(self): + """Return the picture of the device.""" + return self.config_picture + + @property + def state_attributes(self): + """Return the device state attributes.""" + attr = { + ATTR_SOURCE_TYPE: self.source_type + } + + if self.gps: + attr[ATTR_LATITUDE] = self.gps[0] + attr[ATTR_LONGITUDE] = self.gps[1] + attr[ATTR_GPS_ACCURACY] = self.gps_accuracy + + if self.battery: + attr[ATTR_BATTERY] = self.battery + + return attr + + @property + def device_state_attributes(self): + """Return device state attributes.""" + return self._attributes + + @property + def hidden(self): + """If device should be hidden.""" + return self.away_hide and self.state != STATE_HOME + + async def async_seen( + self, host_name: str = None, location_name: str = None, + gps: GPSType = None, gps_accuracy=0, battery: int = None, + attributes: dict = None, + source_type: str = SOURCE_TYPE_GPS, + consider_home: timedelta = None): + """Mark the device as seen.""" + self.source_type = source_type + self.last_seen = dt_util.utcnow() + self.host_name = host_name + self.location_name = location_name + self.consider_home = consider_home or self.consider_home + + if battery: + self.battery = battery + if attributes: + self._attributes.update(attributes) + + self.gps = None + + if gps is not None: + try: + self.gps = float(gps[0]), float(gps[1]) + self.gps_accuracy = gps_accuracy or 0 + except (ValueError, TypeError, IndexError): + self.gps = None + self.gps_accuracy = 0 + LOGGER.warning( + "Could not parse gps value for %s: %s", self.dev_id, gps) + + # pylint: disable=not-an-iterable + await self.async_update() + + def stale(self, now: dt_util.dt.datetime = None): + """Return if device state is stale. + + Async friendly. + """ + return self.last_seen is None or \ + (now or dt_util.utcnow()) - self.last_seen > self.consider_home + + def mark_stale(self): + """Mark the device state as stale.""" + self._state = STATE_NOT_HOME + self.gps = None + self.last_update_home = False + + async def async_update(self): + """Update state of entity. + + This method is a coroutine. + """ + if not self.last_seen: + return + if self.location_name: + self._state = self.location_name + elif self.gps is not None and self.source_type == SOURCE_TYPE_GPS: + zone_state = async_active_zone( + self.hass, self.gps[0], self.gps[1], self.gps_accuracy) + if zone_state is None: + self._state = STATE_NOT_HOME + elif zone_state.entity_id == zone.ENTITY_ID_HOME: + self._state = STATE_HOME + else: + self._state = zone_state.name + elif self.stale(): + self.mark_stale() + else: + self._state = STATE_HOME + self.last_update_home = True + + async def async_added_to_hass(self): + """Add an entity.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if not state: + return + self._state = state.state + self.last_update_home = (state.state == STATE_HOME) + self.last_seen = dt_util.utcnow() + + for attr, var in ( + (ATTR_SOURCE_TYPE, 'source_type'), + (ATTR_GPS_ACCURACY, 'gps_accuracy'), + (ATTR_BATTERY, 'battery'), + ): + if attr in state.attributes: + setattr(self, var, state.attributes[attr]) + + if ATTR_LONGITUDE in state.attributes: + self.gps = (state.attributes[ATTR_LATITUDE], + state.attributes[ATTR_LONGITUDE]) + + +class DeviceScanner: + """Device scanner object.""" + + hass = None # type: HomeAssistantType + + def scan_devices(self) -> List[str]: + """Scan for devices.""" + raise NotImplementedError() + + def async_scan_devices(self) -> Any: + """Scan for devices. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.scan_devices) + + def get_device_name(self, device: str) -> str: + """Get the name of a device.""" + raise NotImplementedError() + + def async_get_device_name(self, device: str) -> Any: + """Get the name of a device. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.get_device_name, device) + + def get_extra_attributes(self, device: str) -> dict: + """Get the extra attributes of a device.""" + raise NotImplementedError() + + def async_get_extra_attributes(self, device: str) -> Any: + """Get the extra attributes of a device. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.get_extra_attributes, device) + + +async def async_load_config(path: str, hass: HomeAssistantType, + consider_home: timedelta): + """Load devices from YAML configuration file. + + This method is a coroutine. + """ + dev_schema = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_ICON, default=None): vol.Any(None, cv.icon), + vol.Optional('track', default=False): cv.boolean, + vol.Optional(CONF_MAC, default=None): + vol.Any(None, vol.All(cv.string, vol.Upper)), + vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean, + vol.Optional('gravatar', default=None): vol.Any(None, cv.string), + vol.Optional('picture', default=None): vol.Any(None, cv.string), + vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All( + cv.time_period, cv.positive_timedelta), + }) + result = [] + try: + devices = await hass.async_add_job( + load_yaml_config_file, path) + except HomeAssistantError as err: + LOGGER.error("Unable to load %s: %s", path, str(err)) + return [] + except FileNotFoundError: + return [] + + for dev_id, device in devices.items(): + # Deprecated option. We just ignore it to avoid breaking change + device.pop('vendor', None) + try: + device = dev_schema(device) + device['dev_id'] = cv.slugify(dev_id) + except vol.Invalid as exp: + async_log_exception(exp, dev_id, devices, hass) + else: + result.append(Device(hass, **device)) + return result + + +def update_config(path: str, dev_id: str, device: Device): + """Add device to YAML configuration file.""" + with open(path, 'a') as out: + device = {device.dev_id: { + ATTR_NAME: device.name, + ATTR_MAC: device.mac, + ATTR_ICON: device.icon, + 'picture': device.config_picture, + 'track': device.track, + CONF_AWAY_HIDE: device.away_hide, + }} + out.write('\n') + out.write(dump(device)) + + +def get_gravatar_for_email(email: str): + """Return an 80px Gravatar for the given email address. + + Async friendly. + """ + import hashlib + url = 'https://www.gravatar.com/avatar/{}.jpg?s=80&d=wavatar' + return url.format(hashlib.md5(email.encode('utf-8').lower()).hexdigest()) diff --git a/homeassistant/components/device_tracker/setup.py b/homeassistant/components/device_tracker/setup.py new file mode 100644 index 00000000000..a74f51c6638 --- /dev/null +++ b/homeassistant/components/device_tracker/setup.py @@ -0,0 +1,184 @@ +"""Device tracker helpers.""" +import asyncio +from typing import Dict, Any, Callable, Optional +from types import ModuleType + +import attr + +from homeassistant.core import callback +from homeassistant.setup import async_prepare_setup_platform +from homeassistant.helpers import config_per_platform +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util import dt as dt_util +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, +) + + +from .const import ( + DOMAIN, + PLATFORM_TYPE_LEGACY, + CONF_SCAN_INTERVAL, + SCAN_INTERVAL, + SOURCE_TYPE_ROUTER, + LOGGER, +) + + +@attr.s +class DeviceTrackerPlatform: + """Class to hold platform information.""" + + LEGACY_SETUP = ( + 'async_get_scanner', + 'get_scanner', + 'async_setup_scanner', + 'setup_scanner', + ) + + name = attr.ib(type=str) + platform = attr.ib(type=ModuleType) + config = attr.ib(type=Dict) + + @property + def type(self): + """Return platform type.""" + for methods, platform_type in ( + (self.LEGACY_SETUP, PLATFORM_TYPE_LEGACY), + ): + for meth in methods: + if hasattr(self.platform, meth): + return platform_type + + return None + + async def async_setup_legacy(self, hass, tracker, discovery_info=None): + """Set up a legacy platform.""" + LOGGER.info("Setting up %s.%s", DOMAIN, self.type) + try: + scanner = None + setup = None + if hasattr(self.platform, 'async_get_scanner'): + scanner = await self.platform.async_get_scanner( + hass, {DOMAIN: self.config}) + elif hasattr(self.platform, 'get_scanner'): + scanner = await hass.async_add_job( + self.platform.get_scanner, hass, {DOMAIN: self.config}) + elif hasattr(self.platform, 'async_setup_scanner'): + setup = await self.platform.async_setup_scanner( + hass, self.config, tracker.async_see, discovery_info) + elif hasattr(self.platform, 'setup_scanner'): + setup = await hass.async_add_job( + self.platform.setup_scanner, hass, self.config, + tracker.see, discovery_info) + else: + raise HomeAssistantError( + "Invalid legacy device_tracker platform.") + + if scanner: + async_setup_scanner_platform( + hass, self.config, scanner, tracker.async_see, self.type) + return + + if not setup: + LOGGER.error("Error setting up platform %s", self.type) + return + + except Exception: # pylint: disable=broad-except + LOGGER.exception("Error setting up platform %s", self.type) + + +async def async_extract_config(hass, config): + """Extract device tracker config and split between legacy and modern.""" + legacy = [] + + for platform in await asyncio.gather(*[ + async_create_platform_type(hass, config, p_type, p_config) + for p_type, p_config in config_per_platform(config, DOMAIN) + ]): + if platform is None: + continue + + if platform.type == PLATFORM_TYPE_LEGACY: + legacy.append(platform) + else: + raise ValueError("Unable to determine type for {}: {}".format( + platform.name, platform.type)) + + return legacy + + +async def async_create_platform_type(hass, config, p_type, p_config) \ + -> Optional[DeviceTrackerPlatform]: + """Determine type of platform.""" + platform = await async_prepare_setup_platform( + hass, config, DOMAIN, p_type) + + if platform is None: + return None + + return DeviceTrackerPlatform(p_type, platform, p_config) + + +@callback +def async_setup_scanner_platform(hass: HomeAssistantType, config: ConfigType, + scanner: Any, async_see_device: Callable, + platform: str): + """Set up the connect scanner-based platform to device tracker. + + This method must be run in the event loop. + """ + interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + update_lock = asyncio.Lock() + scanner.hass = hass + + # Initial scan of each mac we also tell about host name for config + seen = set() # type: Any + + async def async_device_tracker_scan(now: dt_util.dt.datetime): + """Handle interval matches.""" + if update_lock.locked(): + LOGGER.warning( + "Updating device list from %s took longer than the scheduled " + "scan interval %s", platform, interval) + return + + async with update_lock: + found_devices = await scanner.async_scan_devices() + + for mac in found_devices: + if mac in seen: + host_name = None + else: + host_name = await scanner.async_get_device_name(mac) + seen.add(mac) + + try: + extra_attributes = \ + await scanner.async_get_extra_attributes(mac) + except NotImplementedError: + extra_attributes = dict() + + kwargs = { + 'mac': mac, + 'host_name': host_name, + 'source_type': SOURCE_TYPE_ROUTER, + 'attributes': { + 'scanner': scanner.__class__.__name__, + **extra_attributes + } + } + + zone_home = hass.states.get(hass.components.zone.ENTITY_ID_HOME) + if zone_home: + kwargs['gps'] = [zone_home.attributes[ATTR_LATITUDE], + zone_home.attributes[ATTR_LONGITUDE]] + kwargs['gps_accuracy'] = 0 + + hass.async_create_task(async_see_device(**kwargs)) + + async_track_time_interval(hass, async_device_tracker_scan, interval) + hass.async_create_task(async_device_tracker_scan(None)) diff --git a/homeassistant/components/dialogflow/__init__.py b/homeassistant/components/dialogflow/__init__.py index a6134d4b19c..3bf11a46098 100644 --- a/homeassistant/components/dialogflow/__init__.py +++ b/homeassistant/components/dialogflow/__init__.py @@ -8,9 +8,10 @@ from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent, template, config_entry_flow -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN -DOMAIN = 'dialogflow' + +_LOGGER = logging.getLogger(__name__) SOURCE = "Home Assistant Dialogflow" @@ -83,16 +84,6 @@ async def async_unload_entry(hass, entry): async_remove_entry = config_entry_flow.webhook_async_remove_entry -config_entry_flow.register_webhook_flow( - DOMAIN, - 'Dialogflow Webhook', - { - 'dialogflow_url': 'https://dialogflow.com/docs/fulfillment#webhook', - 'docs_url': 'https://www.home-assistant.io/components/dialogflow/' - } -) - - def dialogflow_error_response(message, error): """Return a response saying the error message.""" dialogflow_response = DialogflowResponse(message['result']['parameters']) diff --git a/homeassistant/components/dialogflow/config_flow.py b/homeassistant/components/dialogflow/config_flow.py new file mode 100644 index 00000000000..aa6f9f6f515 --- /dev/null +++ b/homeassistant/components/dialogflow/config_flow.py @@ -0,0 +1,13 @@ +"""Config flow for DialogFlow.""" +from homeassistant.helpers import config_entry_flow +from .const import DOMAIN + + +config_entry_flow.register_webhook_flow( + DOMAIN, + 'Dialogflow Webhook', + { + 'dialogflow_url': 'https://dialogflow.com/docs/fulfillment#webhook', + 'docs_url': 'https://www.home-assistant.io/components/dialogflow/' + } +) diff --git a/homeassistant/components/dialogflow/const.py b/homeassistant/components/dialogflow/const.py new file mode 100644 index 00000000000..476cb480d94 --- /dev/null +++ b/homeassistant/components/dialogflow/const.py @@ -0,0 +1,3 @@ +"""Const for DialogFlow.""" + +DOMAIN = "dialogflow" diff --git a/homeassistant/components/dialogflow/manifest.json b/homeassistant/components/dialogflow/manifest.json index d136b8a984d..aa8b584aeca 100644 --- a/homeassistant/components/dialogflow/manifest.json +++ b/homeassistant/components/dialogflow/manifest.json @@ -1,6 +1,7 @@ { "domain": "dialogflow", "name": "Dialogflow", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/dialogflow", "requirements": [], "dependencies": [ diff --git a/homeassistant/components/discord/notify.py b/homeassistant/components/discord/notify.py index 2e3d2eee9e9..75a434a3739 100644 --- a/homeassistant/components/discord/notify.py +++ b/homeassistant/components/discord/notify.py @@ -46,7 +46,7 @@ class DiscordNotificationService(BaseNotificationService): import discord discord.VoiceClient.warn_nacl = False - discord_bot = discord.Client(loop=self.hass.loop) + discord_bot = discord.Client() images = None if ATTR_TARGET not in kwargs: diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 900cbda74d4..0541b5d223a 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -24,19 +24,14 @@ DOMAIN = 'discovery' SCAN_INTERVAL = timedelta(seconds=300) SERVICE_APPLE_TV = 'apple_tv' -SERVICE_AXIS = 'axis' SERVICE_DAIKIN = 'daikin' -SERVICE_DECONZ = 'deconz' SERVICE_DLNA_DMR = 'dlna_dmr' SERVICE_ENIGMA2 = 'enigma2' SERVICE_FREEBOX = 'freebox' SERVICE_HASS_IOS_APP = 'hass_ios' SERVICE_HASSIO = 'hassio' -SERVICE_HOMEKIT = 'homekit' SERVICE_HEOS = 'heos' -SERVICE_HUE = 'philips_hue' SERVICE_IGD = 'igd' -SERVICE_IKEA_TRADFRI = 'ikea_tradfri' SERVICE_KONNECTED = 'konnected' SERVICE_MOBILE_APP = 'hass_mobile_app' SERVICE_NETGEAR = 'netgear_router' @@ -51,15 +46,10 @@ SERVICE_WINK = 'wink' SERVICE_XIAOMI_GW = 'xiaomi_gw' CONFIG_ENTRY_HANDLERS = { - SERVICE_AXIS: 'axis', SERVICE_DAIKIN: 'daikin', - SERVICE_DECONZ: 'deconz', - 'esphome': 'esphome', 'google_cast': 'cast', SERVICE_HEOS: 'heos', - SERVICE_HUE: 'hue', SERVICE_TELLDUSLIVE: 'tellduslive', - SERVICE_IKEA_TRADFRI: 'tradfri', 'sonos': 'sonos', SERVICE_IGD: 'upnp', } @@ -101,12 +91,22 @@ SERVICE_HANDLERS = { } OPTIONAL_SERVICE_HANDLERS = { - SERVICE_HOMEKIT: ('homekit_controller', None), SERVICE_DLNA_DMR: ('media_player', 'dlna_dmr'), } -DEFAULT_ENABLED = list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS) -DEFAULT_DISABLED = list(OPTIONAL_SERVICE_HANDLERS) +MIGRATED_SERVICE_HANDLERS = { + 'axis': None, + 'deconz': None, + 'esphome': None, + 'ikea_tradfri': None, + 'homekit': None, + 'philips_hue': None +} + +DEFAULT_ENABLED = list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS) + \ + list(MIGRATED_SERVICE_HANDLERS) +DEFAULT_DISABLED = list(OPTIONAL_SERVICE_HANDLERS) + \ + list(MIGRATED_SERVICE_HANDLERS) CONF_IGNORE = 'ignore' CONF_ENABLE = 'enable' @@ -153,6 +153,9 @@ async def async_setup(hass, config): async def new_service_found(service, info): """Handle a new service if one is found.""" + if service in MIGRATED_SERVICE_HANDLERS: + return + if service in ignored_platforms: logger.info("Ignoring service: %s %s", service, info) return diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 6f29bd65d56..dd348d1fbbc 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -103,7 +103,6 @@ async def async_start_event_handler( requester, listen_port=server_port, listen_host=server_host, - loop=hass.loop, callback_url=callback_url_override) await server.start_server() _LOGGER.info( diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index a29a0513cee..337a68a77ce 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -63,7 +63,7 @@ class WanIpSensor(Entity): self.hass = hass self._name = name self.hostname = hostname - self.resolver = aiodns.DNSResolver(loop=self.hass.loop) + self.resolver = aiodns.DNSResolver() self.resolver.nameservers = [resolver] self.querytype = 'AAAA' if ipv6 else 'A' self._state = None diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index 477d96770bc..62d3584603a 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -6,8 +6,8 @@ import voluptuous as vol from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( - CONF_DEVICES, CONF_HOST, CONF_MONITORED_CONDITIONS, CONF_NAME, - CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME) + CONF_DEVICES, CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_TOKEN, + CONF_USERNAME) import homeassistant.helpers.config_validation as cv from homeassistant.util import dt as dt_util, slugify @@ -18,25 +18,7 @@ DOMAIN = 'doorbird' API_URL = '/api/{}'.format(DOMAIN) CONF_CUSTOM_URL = 'hass_url_override' -CONF_DOORBELL_EVENTS = 'doorbell_events' -CONF_DOORBELL_NUMS = 'doorbell_numbers' -CONF_RELAY_NUMS = 'relay_numbers' -CONF_MOTION_EVENTS = 'motion_events' - -SENSOR_TYPES = { - 'doorbell': { - 'name': 'Button', - 'device_class': 'occupancy', - }, - 'motion': { - 'name': 'Motion', - 'device_class': 'motion', - }, - 'relay': { - 'name': 'Relay', - 'device_class': 'relay', - } -} +CONF_EVENTS = 'events' RESET_DEVICE_FAVORITES = 'doorbird_reset_favorites' @@ -44,19 +26,15 @@ DEVICE_SCHEMA = vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_DOORBELL_NUMS, default=[1]): vol.All( - cv.ensure_list, [cv.positive_int]), - vol.Optional(CONF_RELAY_NUMS, default=[1]): vol.All( - cv.ensure_list, [cv.positive_int]), + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_EVENTS, default=[]): vol.All( + cv.ensure_list, [cv.string]), vol.Optional(CONF_CUSTOM_URL): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME): cv.string }) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_TOKEN): cv.string, vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [DEVICE_SCHEMA]) }), }, extra=vol.ALLOW_EXTRA) @@ -66,13 +44,8 @@ def setup(hass, config): """Set up the DoorBird component.""" from doorbirdpy import DoorBird - token = config[DOMAIN].get(CONF_TOKEN) - # Provide an endpoint for the doorstations to call to trigger events - hass.http.register_view(DoorBirdRequestView(token)) - - # Provide an endpoint for the user to call to clear device changes - hass.http.register_view(DoorBirdCleanupView(token)) + hass.http.register_view(DoorBirdRequestView) doorstations = [] @@ -80,10 +53,9 @@ def setup(hass, config): device_ip = doorstation_config.get(CONF_HOST) username = doorstation_config.get(CONF_USERNAME) password = doorstation_config.get(CONF_PASSWORD) - doorbell_nums = doorstation_config.get(CONF_DOORBELL_NUMS) - relay_nums = doorstation_config.get(CONF_RELAY_NUMS) custom_url = doorstation_config.get(CONF_CUSTOM_URL) - events = doorstation_config.get(CONF_MONITORED_CONDITIONS) + events = doorstation_config.get(CONF_EVENTS) + token = doorstation_config.get(CONF_TOKEN) name = (doorstation_config.get(CONF_NAME) or 'DoorBird {}'.format(index + 1)) @@ -92,7 +64,7 @@ def setup(hass, config): if status[0]: doorstation = ConfiguredDoorBird(device, name, events, custom_url, - doorbell_nums, relay_nums, token) + token) doorstations.append(doorstation) _LOGGER.info('Connected to DoorBird "%s" as %s@%s', doorstation.name, username, device_ip) @@ -108,7 +80,7 @@ def setup(hass, config): # Subscribe to doorbell or motion events if events: try: - doorstation.update_schedule(hass) + doorstation.register_events(hass) except HTTPError: hass.components.persistent_notification.create( 'Doorbird configuration failed. Please verify that API ' @@ -124,15 +96,15 @@ def setup(hass, config): def _reset_device_favorites_handler(event): """Handle clearing favorites on device.""" - slug = event.data.get('slug') + token = event.data.get('token') - if slug is None: + if token is None: return - doorstation = get_doorstation_by_slug(hass, slug) + doorstation = get_doorstation_by_token(hass, token) if doorstation is None: - _LOGGER.error('Device not found %s', format(slug)) + _LOGGER.error('Device not found for provided token.') # Clear webhooks favorites = doorstation.device.favorites() @@ -146,30 +118,22 @@ def setup(hass, config): return True -def get_doorstation_by_slug(hass, slug): +def get_doorstation_by_token(hass, token): """Get doorstation by slug.""" for doorstation in hass.data[DOMAIN]: - if slugify(doorstation.name) in slug: + if token == doorstation.token: return doorstation -def handle_event(event): - """Handle dummy events.""" - return None - - class ConfiguredDoorBird(): """Attach additional information to pass along with configured device.""" - def __init__(self, device, name, events, custom_url, doorbell_nums, - relay_nums, token): + def __init__(self, device, name, events, custom_url, token): """Initialize configured device.""" self._name = name self._device = device self._custom_url = custom_url - self._monitored_events = events - self._doorbell_nums = doorbell_nums - self._relay_nums = relay_nums + self._events = events self._token = token @property @@ -187,14 +151,13 @@ class ConfiguredDoorBird(): """Get custom url for device.""" return self._custom_url - def update_schedule(self, hass): - """Register monitored sensors and deregister others.""" - from doorbirdpy import DoorBirdScheduleEntrySchedule - - # Create a new schedule (24/7) - schedule = DoorBirdScheduleEntrySchedule() - schedule.add_weekday(0, 604800) # seconds in a week + @property + def token(self): + """Get token for device.""" + return self._token + def register_events(self, hass): + """Register events on device.""" # Get the URL of this server hass_url = hass.config.api.base_url @@ -202,98 +165,39 @@ class ConfiguredDoorBird(): if self.custom_url is not None: hass_url = self.custom_url - # For all sensor types (enabled + disabled) - for sensor_type in SENSOR_TYPES: - name = '{} {}'.format(self.name, SENSOR_TYPES[sensor_type]['name']) - slug = slugify(name) + for event in self._events: + event = self._get_event_name(event) - url = '{}{}/{}?token={}'.format(hass_url, API_URL, slug, - self._token) - if sensor_type in self._monitored_events: - # Enabled -> register - self._register_event(url, sensor_type, schedule) - _LOGGER.info('Registered for %s pushes from DoorBird "%s". ' - 'Use the "%s_%s" event for automations.', - sensor_type, self.name, DOMAIN, slug) + self._register_event(hass_url, event) - # Register a dummy listener so event is listed in GUI - hass.bus.listen('{}_{}'.format(DOMAIN, slug), handle_event) - else: - # Disabled -> deregister - self._deregister_event(url, sensor_type) - _LOGGER.info('Deregistered %s pushes from DoorBird "%s". ' - 'If any old favorites or schedules remain, ' - 'follow the instructions in the component ' - 'documentation to clear device registrations.', - sensor_type, self.name) + _LOGGER.info('Successfully registered URL for %s on %s', + event, self.name) - def _register_event(self, hass_url, event, schedule): + @property + def slug(self): + """Get device slug.""" + return slugify(self._name) + + def _get_event_name(self, event): + return '{}_{}'.format(self.slug, event) + + def _register_event(self, hass_url, event): """Add a schedule entry in the device for a sensor.""" - from doorbirdpy import DoorBirdScheduleEntryOutput + url = '{}{}/{}?token={}'.format(hass_url, API_URL, event, self._token) # Register HA URL as webhook if not already, then get the ID - if not self.webhook_is_registered(hass_url): - self.device.change_favorite('http', 'Home Assistant ({} events)' - .format(event), hass_url) + if not self.webhook_is_registered(url): + self.device.change_favorite('http', 'Home Assistant ({})' + .format(event), url) - fav_id = self.get_webhook_id(hass_url) + fav_id = self.get_webhook_id(url) if not fav_id: _LOGGER.warning('Could not find favorite for URL "%s". ' - 'Skipping sensor "%s".', hass_url, event) + 'Skipping sensor "%s"', url, event) return - # Add event handling to device schedule - output = DoorBirdScheduleEntryOutput(event='http', - param=fav_id, - schedule=schedule) - - if event == 'doorbell': - # Repeat edit for each monitored doorbell number - for doorbell in self._doorbell_nums: - entry = self.device.get_schedule_entry(event, str(doorbell)) - entry.output.append(output) - self.device.change_schedule(entry) - elif event == 'relay': - # Repeat edit for each monitored doorbell number - for relay in self._relay_nums: - entry = self.device.get_schedule_entry(event, str(relay)) - entry.output.append(output) - else: - entry = self.device.get_schedule_entry(event) - entry.output.append(output) - self.device.change_schedule(entry) - - def _deregister_event(self, hass_url, event): - """Remove the schedule entry in the device for a sensor.""" - # Find the right favorite and delete it - fav_id = self.get_webhook_id(hass_url) - if not fav_id: - return - - self._device.delete_favorite('http', fav_id) - - if event == 'doorbell': - # Delete the matching schedule for each doorbell number - for doorbell in self._doorbell_nums: - self._delete_schedule_action(event, fav_id, str(doorbell)) - else: - self._delete_schedule_action(event, fav_id) - - def _delete_schedule_action(self, sensor, fav_id, param=""): - """Remove the HA output from a schedule.""" - entries = self._device.schedule() - for entry in entries: - if entry.input != sensor or entry.param != param: - continue - - for action in entry.output: - if action.event == 'http' and action.param == fav_id: - entry.output.remove(action) - - self._device.change_schedule(entry) - - def webhook_is_registered(self, ha_url, favs=None) -> bool: + def webhook_is_registered(self, url, favs=None) -> bool: """Return whether the given URL is registered as a device favorite.""" favs = favs if favs else self.device.favorites() @@ -301,12 +205,12 @@ class ConfiguredDoorBird(): return False for fav in favs['http'].values(): - if fav['value'] == ha_url: + if fav['value'] == url: return True return False - def get_webhook_id(self, ha_url, favs=None) -> str or None: + def get_webhook_id(self, url, favs=None) -> str or None: """ Return the device favorite ID for the given URL. @@ -318,7 +222,7 @@ class ConfiguredDoorBird(): return None for fav_id in favs['http']: - if favs['http'][fav_id]['value'] == ha_url: + if favs['http'][fav_id]['value'] == url: return fav_id return None @@ -340,72 +244,33 @@ class DoorBirdRequestView(HomeAssistantView): requires_auth = False url = API_URL name = API_URL[1:].replace('/', ':') - extra_urls = [API_URL + '/{sensor}'] - - def __init__(self, token): - """Initialize view.""" - HomeAssistantView.__init__(self) - self._token = token + extra_urls = [API_URL + '/{event}'] # pylint: disable=no-self-use - async def get(self, request, sensor): + async def get(self, request, event): """Respond to requests from the device.""" from aiohttp import web hass = request.app['hass'] - request_token = request.query.get('token') + token = request.query.get('token') - authenticated = request_token == self._token + device = get_doorstation_by_token(hass, token) - if request_token == '' or not authenticated: - return web.Response(status=401, text='Unauthorized') + if device is None: + return web.Response(status=401, text='Invalid token provided.') - doorstation = get_doorstation_by_slug(hass, sensor) - - if doorstation: - event_data = doorstation.get_event_data() + if device: + event_data = device.get_event_data() else: event_data = {} - hass.bus.async_fire('{}_{}'.format(DOMAIN, sensor), event_data) + if event == 'clear': + hass.bus.async_fire(RESET_DEVICE_FAVORITES, + {'token': token}) + + message = 'HTTP Favorites cleared for {}'.format(device.slug) + return web.Response(status=200, text=message) + + hass.bus.async_fire('{}_{}'.format(DOMAIN, event), event_data) return web.Response(status=200, text='OK') - - -class DoorBirdCleanupView(HomeAssistantView): - """Provide a URL to call to delete ALL webhooks/schedules.""" - - requires_auth = False - url = API_URL + '/clear/{slug}' - name = 'DoorBird Cleanup' - - def __init__(self, token): - """Initialize view.""" - HomeAssistantView.__init__(self) - self._token = token - - # pylint: disable=no-self-use - async def get(self, request, slug): - """Act on requests.""" - from aiohttp import web - hass = request.app['hass'] - - request_token = request.query.get('token') - - authenticated = request_token == self._token - - if request_token == '' or not authenticated: - return web.Response(status=401, text='Unauthorized') - - device = get_doorstation_by_slug(hass, slug) - - # No matching device - if device is None: - return web.Response(status=404, - text='Device slug {} not found'.format(slug)) - - hass.bus.async_fire(RESET_DEVICE_FAVORITES, - {'slug': slug}) - - message = 'Clearing schedule for {}'.format(slug) - return web.Response(status=200, text=message) diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index 9a20a91c758..b4bd40c442c 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -57,8 +57,7 @@ class DoorBirdCamera(Camera): self._last_update = datetime.datetime.min super().__init__() - @property - def stream_source(self): + async def stream_source(self): """Return the stream source.""" return self._stream_url @@ -81,7 +80,7 @@ class DoorBirdCamera(Camera): try: websession = async_get_clientsession(self.hass) - with async_timeout.timeout(_TIMEOUT, loop=self.hass.loop): + with async_timeout.timeout(_TIMEOUT): response = await websession.get(self._url) self._last_image = await response.read() diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index d7acc5c28bf..15b2b7fd0de 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -223,8 +223,7 @@ async def async_setup_platform(hass, config, async_add_entities, update_entities_telegram({}) # throttle reconnect attempts - await asyncio.sleep(config[CONF_RECONNECT_INTERVAL], - loop=hass.loop) + await asyncio.sleep(config[CONF_RECONNECT_INTERVAL]) # Can't be hass.async_add_job because job runs forever hass.loop.create_task(connect_and_reconnect()) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 44a9c6e53ef..632fdab12a4 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -453,7 +453,7 @@ def get_entity_state(config, entity): if cached_state is None: data[STATE_ON] = entity.state != STATE_OFF if data[STATE_ON]: - data[STATE_BRIGHTNESS] = entity.attributes.get(ATTR_BRIGHTNESS) + data[STATE_BRIGHTNESS] = entity.attributes.get(ATTR_BRIGHTNESS, 0) hue_sat = entity.attributes.get(ATTR_HS_COLOR, None) if hue_sat is not None: hue = hue_sat[0] diff --git a/homeassistant/components/emulated_roku/.translations/es.json b/homeassistant/components/emulated_roku/.translations/es.json index a4c8503b3f3..f727c8bf522 100644 --- a/homeassistant/components/emulated_roku/.translations/es.json +++ b/homeassistant/components/emulated_roku/.translations/es.json @@ -6,9 +6,12 @@ "step": { "user": { "data": { + "advertise_ip": "IP para anunciar", + "advertise_port": "Puerto para anunciar", "host_ip": "IP del host", "listen_port": "Puerto de escucha", - "name": "Nombre" + "name": "Nombre", + "upnp_bind_multicast": "Enlazar multicast (verdadero/falso)" }, "title": "Definir la configuraci\u00f3n del servidor" } diff --git a/homeassistant/components/emulated_roku/manifest.json b/homeassistant/components/emulated_roku/manifest.json index 3b8eba396ec..ba68ce94951 100644 --- a/homeassistant/components/emulated_roku/manifest.json +++ b/homeassistant/components/emulated_roku/manifest.json @@ -1,6 +1,7 @@ { "domain": "emulated_roku", "name": "Emulated roku", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/emulated_roku", "requirements": [ "emulated_roku==0.1.8" diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 6fee88b39fc..1a816bc91d9 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -3,7 +3,7 @@ "name": "Enphase envoy", "documentation": "https://www.home-assistant.io/components/enphase_envoy", "requirements": [ - "envoy_reader==0.3" + "envoy_reader==0.4" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index d7a015e8e45..84b98846c2a 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -106,7 +106,7 @@ async def async_setup(hass, config): zones = conf.get(CONF_ZONES) partitions = conf.get(CONF_PARTITIONS) connection_timeout = conf.get(CONF_TIMEOUT) - sync_connect = asyncio.Future(loop=hass.loop) + sync_connect = asyncio.Future() controller = EnvisalinkAlarmPanel( host, port, panel_type, version, user, password, zone_dump, diff --git a/homeassistant/components/esphome/.translations/ca.json b/homeassistant/components/esphome/.translations/ca.json index f9c60979c8d..2e6f8dc62ad 100644 --- a/homeassistant/components/esphome/.translations/ca.json +++ b/homeassistant/components/esphome/.translations/ca.json @@ -8,6 +8,7 @@ "invalid_password": "Contrasenya inv\u00e0lida!", "resolve_error": "No s'ha pogut trobar l'adre\u00e7a de l'ESP. Si l'error persisteix, configura una adre\u00e7a IP est\u00e0tica: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/.translations/en.json b/homeassistant/components/esphome/.translations/en.json index 3a73e54c345..f5236d1735d 100644 --- a/homeassistant/components/esphome/.translations/en.json +++ b/homeassistant/components/esphome/.translations/en.json @@ -8,6 +8,7 @@ "invalid_password": "Invalid password!", "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/.translations/fr.json b/homeassistant/components/esphome/.translations/fr.json index b230a73c354..26fa4ec0bd4 100644 --- a/homeassistant/components/esphome/.translations/fr.json +++ b/homeassistant/components/esphome/.translations/fr.json @@ -8,6 +8,7 @@ "invalid_password": "Mot de passe invalide !", "resolve_error": "Impossible de r\u00e9soudre l'adresse de l'ESP. Si cette erreur persiste, veuillez d\u00e9finir une adresse IP statique: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/.translations/ru.json b/homeassistant/components/esphome/.translations/ru.json index 9777a920a94..1405112c070 100644 --- a/homeassistant/components/esphome/.translations/ru.json +++ b/homeassistant/components/esphome/.translations/ru.json @@ -8,6 +8,7 @@ "invalid_password": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c!", "resolve_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0430\u0434\u0440\u0435\u0441 ESP. \u0415\u0441\u043b\u0438 \u044d\u0442\u0430 \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0432\u0442\u043e\u0440\u044f\u0435\u0442\u0441\u044f, \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0441\u0442\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u0439 IP-\u0430\u0434\u0440\u0435\u0441: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/.translations/sl.json b/homeassistant/components/esphome/.translations/sl.json index 93ca607aabe..5f4e9d3e4c4 100644 --- a/homeassistant/components/esphome/.translations/sl.json +++ b/homeassistant/components/esphome/.translations/sl.json @@ -8,6 +8,7 @@ "invalid_password": "Neveljavno geslo!", "resolve_error": "Ne moremo razre\u0161iti naslova ESP. \u010ce se napaka ponovi, prosimo nastavite stati\u010dni IP naslov: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/.translations/sv.json b/homeassistant/components/esphome/.translations/sv.json index da977af601a..37788522e4f 100644 --- a/homeassistant/components/esphome/.translations/sv.json +++ b/homeassistant/components/esphome/.translations/sv.json @@ -8,6 +8,7 @@ "invalid_password": "Ogiltigt l\u00f6senord!", "resolve_error": "Det g\u00e5r inte att hitta IP-adressen f\u00f6r ESP med DNS-namnet. Om det h\u00e4r felet kvarst\u00e5r anger du en statisk IP-adress: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/.translations/zh-Hant.json b/homeassistant/components/esphome/.translations/zh-Hant.json index 9a5821f0b8f..721f4362103 100644 --- a/homeassistant/components/esphome/.translations/zh-Hant.json +++ b/homeassistant/components/esphome/.translations/zh-Hant.json @@ -8,6 +8,7 @@ "invalid_password": "\u5bc6\u78bc\u7121\u6548\uff01", "resolve_error": "\u7121\u6cd5\u89e3\u6790 ESP \u4f4d\u5740\uff0c\u5047\u5982\u6b64\u932f\u8aa4\u6301\u7e8c\u767c\u751f\uff0c\u8acb\u53c3\u8003\u8aaa\u660e\u8a2d\u5b9a\u70ba\u975c\u614b\u56fa\u5b9a IP \uff1a https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome\uff1a{name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index e5feedd8421..395c145e5df 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -2,45 +2,40 @@ import asyncio import logging import math -from typing import Any, Dict, List, Optional, TYPE_CHECKING, Callable, Tuple +from typing import Any, Callable, Dict, List, Optional -import attr +from aioesphomeapi import ( + APIClient, APIConnectionError, DeviceInfo, EntityInfo, EntityState, + ServiceCall, UserService, UserServiceArgType) import voluptuous as vol from homeassistant import const from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, \ - EVENT_HOMEASSISTANT_STOP -from homeassistant.core import callback, Event, State -import homeassistant.helpers.device_registry as dr +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_PORT, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import Event, State, callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv from homeassistant.helpers import template -from homeassistant.helpers.dispatcher import async_dispatcher_connect, \ - async_dispatcher_send +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change -from homeassistant.helpers.template import Template from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.storage import Store -from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ConfigType, HomeAssistantType # Import config flow so that it's added to the registry from .config_flow import EsphomeFlowHandler # noqa - -if TYPE_CHECKING: - from aioesphomeapi import APIClient, EntityInfo, EntityState, DeviceInfo, \ - ServiceCall, UserService +from .entry_data import ( + DATA_KEY, DISPATCHER_ON_DEVICE_UPDATE, DISPATCHER_ON_LIST, + DISPATCHER_ON_STATE, DISPATCHER_REMOVE_ENTITY, DISPATCHER_UPDATE_ENTITY, + RuntimeEntryData) DOMAIN = 'esphome' _LOGGER = logging.getLogger(__name__) -DISPATCHER_UPDATE_ENTITY = 'esphome_{entry_id}_update_{component_key}_{key}' -DISPATCHER_REMOVE_ENTITY = 'esphome_{entry_id}_remove_{component_key}_{key}' -DISPATCHER_ON_LIST = 'esphome_{entry_id}_on_list' -DISPATCHER_ON_DEVICE_UPDATE = 'esphome_{entry_id}_on_device_update' -DISPATCHER_ON_STATE = 'esphome_{entry_id}_on_state' - STORAGE_KEY = 'esphome.{}' STORAGE_VERSION = 1 @@ -60,99 +55,6 @@ HA_COMPONENTS = [ CONFIG_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) -@attr.s -class RuntimeEntryData: - """Store runtime data for esphome config entries.""" - - entry_id = attr.ib(type=str) - client = attr.ib(type='APIClient') - store = attr.ib(type=Store) - reconnect_task = attr.ib(type=Optional[asyncio.Task], default=None) - state = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict) - info = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict) - services = attr.ib(type=Dict[int, 'UserService'], factory=dict) - available = attr.ib(type=bool, default=False) - device_info = attr.ib(type='DeviceInfo', default=None) - cleanup_callbacks = attr.ib(type=List[Callable[[], None]], factory=list) - disconnect_callbacks = attr.ib(type=List[Callable[[], None]], factory=list) - - def async_update_entity(self, hass: HomeAssistantType, component_key: str, - key: int) -> None: - """Schedule the update of an entity.""" - signal = DISPATCHER_UPDATE_ENTITY.format( - entry_id=self.entry_id, component_key=component_key, key=key) - async_dispatcher_send(hass, signal) - - def async_remove_entity(self, hass: HomeAssistantType, component_key: str, - key: int) -> None: - """Schedule the removal of an entity.""" - signal = DISPATCHER_REMOVE_ENTITY.format( - entry_id=self.entry_id, component_key=component_key, key=key) - async_dispatcher_send(hass, signal) - - def async_update_static_infos(self, hass: HomeAssistantType, - infos: 'List[EntityInfo]') -> None: - """Distribute an update of static infos to all platforms.""" - signal = DISPATCHER_ON_LIST.format(entry_id=self.entry_id) - async_dispatcher_send(hass, signal, infos) - - def async_update_state(self, hass: HomeAssistantType, - state: 'EntityState') -> None: - """Distribute an update of state information to all platforms.""" - signal = DISPATCHER_ON_STATE.format(entry_id=self.entry_id) - async_dispatcher_send(hass, signal, state) - - def async_update_device_state(self, hass: HomeAssistantType) -> None: - """Distribute an update of a core device state like availability.""" - signal = DISPATCHER_ON_DEVICE_UPDATE.format(entry_id=self.entry_id) - async_dispatcher_send(hass, signal) - - async def async_load_from_store(self) -> Tuple[List['EntityInfo'], - List['UserService']]: - """Load the retained data from store and return de-serialized data.""" - # pylint: disable= redefined-outer-name - from aioesphomeapi import COMPONENT_TYPE_TO_INFO, DeviceInfo, \ - UserService - - restored = await self.store.async_load() - if restored is None: - return [], [] - - self.device_info = _attr_obj_from_dict(DeviceInfo, - **restored.pop('device_info')) - infos = [] - for comp_type, restored_infos in restored.items(): - if comp_type not in COMPONENT_TYPE_TO_INFO: - continue - for info in restored_infos: - cls = COMPONENT_TYPE_TO_INFO[comp_type] - infos.append(_attr_obj_from_dict(cls, **info)) - services = [] - for service in restored.get('services', []): - services.append(UserService.from_dict(service)) - return infos, services - - async def async_save_to_store(self) -> None: - """Generate dynamic data to store and save it to the filesystem.""" - store_data = { - 'device_info': attr.asdict(self.device_info), - 'services': [] - } - - for comp_type, infos in self.info.items(): - store_data[comp_type] = [attr.asdict(info) - for info in infos.values()] - for service in self.services.values(): - store_data['services'].append(service.to_dict()) - - await self.store.async_save(store_data) - - -def _attr_obj_from_dict(cls, **kwargs): - return cls(**{key: kwargs[key] for key in attr.fields_dict(cls) - if key in kwargs}) - - async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Stub to allow setting up this component. @@ -164,10 +66,7 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: """Set up the esphome component.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import APIClient, APIConnectionError - - hass.data.setdefault(DOMAIN, {}) + hass.data.setdefault(DATA_KEY, {}) host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] @@ -179,7 +78,7 @@ async def async_setup_entry(hass: HomeAssistantType, # Store client in per-config-entry hass.data store = Store(hass, STORAGE_VERSION, STORAGE_KEY.format(entry.entry_id), encoder=JSONEncoder) - entry_data = hass.data[DOMAIN][entry.entry_id] = RuntimeEntryData( + entry_data = hass.data[DATA_KEY][entry.entry_id] = RuntimeEntryData( client=cli, entry_id=entry.entry_id, store=store, @@ -194,12 +93,12 @@ async def async_setup_entry(hass: HomeAssistantType, ) @callback - def async_on_state(state: 'EntityState') -> None: + def async_on_state(state: EntityState) -> None: """Send dispatcher updates when a new state is received.""" entry_data.async_update_state(hass, state) @callback - def async_on_service_call(service: 'ServiceCall') -> None: + def async_on_service_call(service: ServiceCall) -> None: """Call service when user automation in ESPHome config is triggered.""" domain, service_name = service.service.split('.', 1) service_data = service.data @@ -261,26 +160,6 @@ async def async_setup_entry(hass: HomeAssistantType, try_connect = await _setup_auto_reconnect_logic(hass, cli, entry, host, on_login) - # This is a bit of a hack: We schedule complete_setup into the - # event loop and return immediately (return True) - # - # Usually, we should avoid that so that HA can track which components - # have been started successfully and which failed to be set up. - # That doesn't work here for two reasons: - # - We have our own re-connect logic - # - Before we do the first try_connect() call, we need to make sure - # all dispatcher event listeners have been connected, so - # async_forward_entry_setup needs to be awaited. However, if we - # would await async_forward_entry_setup() in async_setup_entry(), - # we would end up with a deadlock. - # - # Solution is: complete the setup outside of the async_setup_entry() - # function. HA will wait until the first connection attempt is made - # before starting up (as it should), but if the first connection attempt - # fails we will schedule all next re-connect attempts outside of the - # tracked tasks (hass.loop.create_task). This way HA won't stall startup - # forever until a connection is successful. - async def complete_setup() -> None: """Complete the config entry setup.""" tasks = [] @@ -293,21 +172,18 @@ async def async_setup_entry(hass: HomeAssistantType, entry_data.async_update_static_infos(hass, infos) await _setup_services(hass, entry_data, services) - # If first connect fails, the next re-connect will be scheduled - # outside of _pending_task, in order not to delay HA startup - # indefinitely - await try_connect(is_disconnect=False) + # Create connection attempt outside of HA's tracked task in order + # not to delay startup. + hass.loop.create_task(try_connect(is_disconnect=False)) hass.async_create_task(complete_setup()) return True async def _setup_auto_reconnect_logic(hass: HomeAssistantType, - cli: 'APIClient', + cli: APIClient, entry: ConfigEntry, host: str, on_login): """Set up the re-connect logic for the API client.""" - from aioesphomeapi import APIConnectionError - async def try_connect(tries: int = 0, is_disconnect: bool = True) -> None: """Try connecting to the API client. Will retry if not successful.""" if entry.entry_id not in hass.data[DOMAIN]: @@ -361,7 +237,7 @@ async def _setup_auto_reconnect_logic(hass: HomeAssistantType, async def _async_setup_device_registry(hass: HomeAssistantType, entry: ConfigEntry, - device_info: 'DeviceInfo'): + device_info: DeviceInfo): """Set up device registry feature for a particular config entry.""" sw_version = device_info.esphome_core_version if device_info.compilation_time: @@ -381,8 +257,7 @@ async def _async_setup_device_registry(hass: HomeAssistantType, async def _register_service(hass: HomeAssistantType, entry_data: RuntimeEntryData, - service: 'UserService'): - from aioesphomeapi import UserServiceArgType + service: UserService): service_name = '{}_{}'.format(entry_data.device_info.name, service.name) schema = {} for arg in service.args: @@ -402,7 +277,7 @@ async def _register_service(hass: HomeAssistantType, async def _setup_services(hass: HomeAssistantType, entry_data: RuntimeEntryData, - services: List['UserService']): + services: List[UserService]): old_services = entry_data.services.copy() to_unregister = [] to_register = [] @@ -435,7 +310,7 @@ async def _setup_services(hass: HomeAssistantType, async def _cleanup_instance(hass: HomeAssistantType, entry: ConfigEntry) -> None: """Cleanup the esphome client if it exists.""" - data = hass.data[DOMAIN].pop(entry.entry_id) # type: RuntimeEntryData + data = hass.data[DATA_KEY].pop(entry.entry_id) # type: RuntimeEntryData if data.reconnect_task is not None: data.reconnect_task.cancel() for disconnect_cb in data.disconnect_callbacks: @@ -478,7 +353,7 @@ async def platform_async_setup_entry(hass: HomeAssistantType, entry_data.state[component_key] = {} @callback - def async_list_entities(infos: List['EntityInfo']): + def async_list_entities(infos: List[EntityInfo]): """Update entities of this platform when entities are listed.""" old_infos = entry_data.info[component_key] new_infos = {} @@ -509,7 +384,7 @@ async def platform_async_setup_entry(hass: HomeAssistantType, ) @callback - def async_entity_state(state: 'EntityState'): + def async_entity_state(state: EntityState): """Notify the appropriate entity of an updated state.""" if not isinstance(state, state_type): return @@ -530,6 +405,7 @@ def esphome_state_property(func): """ @property def _wrapper(self): + # pylint: disable=protected-access if self._state is None: return None val = func(self) @@ -614,22 +490,22 @@ class EsphomeEntity(Entity): @property def _entry_data(self) -> RuntimeEntryData: - return self.hass.data[DOMAIN][self._entry_id] + return self.hass.data[DATA_KEY][self._entry_id] @property - def _static_info(self) -> 'EntityInfo': + def _static_info(self) -> EntityInfo: return self._entry_data.info[self._component_key][self._key] @property - def _device_info(self) -> 'DeviceInfo': + def _device_info(self) -> DeviceInfo: return self._entry_data.device_info @property - def _client(self) -> 'APIClient': + def _client(self) -> APIClient: return self._entry_data.client @property - def _state(self) -> 'Optional[EntityState]': + def _state(self) -> Optional[EntityState]: try: return self._entry_data.state[self._component_key][self._key] except KeyError: diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 6a6f9bfac1c..75a7235c58f 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -1,23 +1,18 @@ """Support for ESPHome binary sensors.""" import logging -from typing import TYPE_CHECKING, Optional +from typing import Optional + +from aioesphomeapi import BinarySensorInfo, BinarySensorState from homeassistant.components.binary_sensor import BinarySensorDevice from . import EsphomeEntity, platform_async_setup_entry -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import BinarySensorInfo, BinarySensorState # noqa - _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): """Set up ESPHome binary sensors based on a config entry.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import BinarySensorInfo, BinarySensorState # noqa - await platform_async_setup_entry( hass, entry, async_add_entities, component_key='binary_sensor', @@ -30,11 +25,11 @@ class EsphomeBinarySensor(EsphomeEntity, BinarySensorDevice): """A binary sensor implementation for ESPHome.""" @property - def _static_info(self) -> 'BinarySensorInfo': + def _static_info(self) -> BinarySensorInfo: return super()._static_info @property - def _state(self) -> Optional['BinarySensorState']: + def _state(self) -> Optional[BinarySensorState]: return super()._state @property diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index 64e73dc8784..54f774bc426 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -1,17 +1,16 @@ """Support for ESPHome cameras.""" import asyncio import logging -from typing import Optional, TYPE_CHECKING +from typing import Optional + +from aioesphomeapi import CameraInfo, CameraState from homeassistant.components import camera from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType -from . import EsphomeEntity, platform_async_setup_entry -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import CameraInfo, CameraState # noqa +from . import EsphomeEntity, platform_async_setup_entry _LOGGER = logging.getLogger(__name__) @@ -19,9 +18,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry, async_add_entities) -> None: """Set up esphome cameras based on a config entry.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import CameraInfo, CameraState # noqa - await platform_async_setup_entry( hass, entry, async_add_entities, component_key='camera', @@ -40,11 +36,11 @@ class EsphomeCamera(Camera, EsphomeEntity): self._image_cond = asyncio.Condition() @property - def _static_info(self) -> 'CameraInfo': + def _static_info(self) -> CameraInfo: return super()._static_info @property - def _state(self) -> Optional['CameraState']: + def _state(self) -> Optional[CameraState]: return super()._state async def _on_update(self) -> None: diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 184eb4b6270..33ea5524787 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -1,6 +1,8 @@ """Support for ESPHome climate devices.""" import logging -from typing import TYPE_CHECKING, List, Optional +from typing import List, Optional + +from aioesphomeapi import ClimateInfo, ClimateMode, ClimateState from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( @@ -12,21 +14,15 @@ from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE, STATE_OFF, TEMP_CELSIUS) -from . import EsphomeEntity, platform_async_setup_entry, \ - esphome_state_property, esphome_map_enum - -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import ClimateInfo, ClimateState, ClimateMode # noqa +from . import ( + EsphomeEntity, esphome_map_enum, esphome_state_property, + platform_async_setup_entry) _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): """Set up ESPHome climate devices based on a config entry.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import ClimateInfo, ClimateState # noqa - await platform_async_setup_entry( hass, entry, async_add_entities, component_key='climate', @@ -37,8 +33,6 @@ async def async_setup_entry(hass, entry, async_add_entities): @esphome_map_enum def _climate_modes(): - # pylint: disable=redefined-outer-name - from aioesphomeapi import ClimateMode # noqa return { ClimateMode.OFF: STATE_OFF, ClimateMode.AUTO: STATE_AUTO, @@ -51,11 +45,11 @@ class EsphomeClimateDevice(EsphomeEntity, ClimateDevice): """A climate implementation for ESPHome.""" @property - def _static_info(self) -> 'ClimateInfo': + def _static_info(self) -> ClimateInfo: return super()._static_info @property - def _state(self) -> Optional['ClimateState']: + def _state(self) -> Optional[ClimateState]: return super()._state @property diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index f6b8bb9abd7..ad18e681021 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -7,6 +7,8 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.helpers import ConfigType +from .entry_data import DATA_KEY, RuntimeEntryData + @config_entries.HANDLERS.register('esphome') class EsphomeFlowHandler(config_entries.ConfigFlow): @@ -50,6 +52,11 @@ class EsphomeFlowHandler(config_entries.ConfigFlow): 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 + } + # Only show authentication step if device uses password if device_info.uses_password: return await self.async_step_authenticate() @@ -69,12 +76,28 @@ class EsphomeFlowHandler(config_entries.ConfigFlow): description_placeholders={'name': self._name}, ) - async def async_step_discovery(self, user_input: ConfigType): - """Handle discovery.""" - address = user_input['properties'].get( - 'address', user_input['hostname'][:-1]) + async def async_step_zeroconf(self, user_input: ConfigType): + """Handle zeroconf discovery.""" + # Hostname is format: livingroom.local. + local_name = user_input['hostname'][:-1] + node_name = local_name[:-len('.local')] + address = user_input['properties'].get('address', local_name) + + # Check if already configured for entry in self._async_current_entries(): + already_configured = False if entry.data['host'] == address: + # Is this address already configured? + already_configured = True + elif entry.entry_id in self.hass.data.get(DATA_KEY, {}): + # Does a config entry with this name already exist? + data = self.hass.data[DATA_KEY][ + entry.entry_id] # type: RuntimeEntryData + # Node names are unique in the network + if data.device_info is not None: + already_configured = data.device_info.name == node_name + + if already_configured: return self.async_abort( reason='already_configured' ) diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index a3ef15fa4c7..b69b62075db 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -1,6 +1,8 @@ """Support for ESPHome covers.""" import logging -from typing import TYPE_CHECKING, Optional +from typing import Optional + +from aioesphomeapi import CoverInfo, CoverState from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, SUPPORT_CLOSE, SUPPORT_CLOSE_TILT, @@ -9,11 +11,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType -from . import EsphomeEntity, platform_async_setup_entry, esphome_state_property - -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import CoverInfo, CoverState # noqa +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry _LOGGER = logging.getLogger(__name__) @@ -21,9 +19,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry, async_add_entities) -> None: """Set up ESPHome covers based on a config entry.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import CoverInfo, CoverState # noqa - await platform_async_setup_entry( hass, entry, async_add_entities, component_key='cover', @@ -36,7 +31,7 @@ class EsphomeCover(EsphomeEntity, CoverDevice): """A cover implementation for ESPHome.""" @property - def _static_info(self) -> 'CoverInfo': + def _static_info(self) -> CoverInfo: return super()._static_info @property @@ -61,7 +56,7 @@ class EsphomeCover(EsphomeEntity, CoverDevice): return self._static_info.assumed_state @property - def _state(self) -> Optional['CoverState']: + def _state(self) -> Optional[CoverState]: return super()._state @esphome_state_property diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py new file mode 100644 index 00000000000..47cadc00653 --- /dev/null +++ b/homeassistant/components/esphome/entry_data.py @@ -0,0 +1,107 @@ +"""Runtime entry data for ESPHome stored in hass.data.""" +import asyncio +from typing import Any, Callable, Dict, List, Optional, Tuple + +from aioesphomeapi import ( + COMPONENT_TYPE_TO_INFO, DeviceInfo, EntityInfo, EntityState, UserService) +import attr + +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import HomeAssistantType + +DATA_KEY = 'esphome' +DISPATCHER_UPDATE_ENTITY = 'esphome_{entry_id}_update_{component_key}_{key}' +DISPATCHER_REMOVE_ENTITY = 'esphome_{entry_id}_remove_{component_key}_{key}' +DISPATCHER_ON_LIST = 'esphome_{entry_id}_on_list' +DISPATCHER_ON_DEVICE_UPDATE = 'esphome_{entry_id}_on_device_update' +DISPATCHER_ON_STATE = 'esphome_{entry_id}_on_state' + + +@attr.s +class RuntimeEntryData: + """Store runtime data for esphome config entries.""" + + entry_id = attr.ib(type=str) + client = attr.ib(type='APIClient') + store = attr.ib(type=Store) + reconnect_task = attr.ib(type=Optional[asyncio.Task], default=None) + state = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict) + info = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict) + services = attr.ib(type=Dict[int, 'UserService'], factory=dict) + available = attr.ib(type=bool, default=False) + device_info = attr.ib(type=DeviceInfo, default=None) + cleanup_callbacks = attr.ib(type=List[Callable[[], None]], factory=list) + disconnect_callbacks = attr.ib(type=List[Callable[[], None]], factory=list) + + def async_update_entity(self, hass: HomeAssistantType, component_key: str, + key: int) -> None: + """Schedule the update of an entity.""" + signal = DISPATCHER_UPDATE_ENTITY.format( + entry_id=self.entry_id, component_key=component_key, key=key) + async_dispatcher_send(hass, signal) + + def async_remove_entity(self, hass: HomeAssistantType, component_key: str, + key: int) -> None: + """Schedule the removal of an entity.""" + signal = DISPATCHER_REMOVE_ENTITY.format( + entry_id=self.entry_id, component_key=component_key, key=key) + async_dispatcher_send(hass, signal) + + def async_update_static_infos(self, hass: HomeAssistantType, + infos: List[EntityInfo]) -> None: + """Distribute an update of static infos to all platforms.""" + signal = DISPATCHER_ON_LIST.format(entry_id=self.entry_id) + async_dispatcher_send(hass, signal, infos) + + def async_update_state(self, hass: HomeAssistantType, + state: EntityState) -> None: + """Distribute an update of state information to all platforms.""" + signal = DISPATCHER_ON_STATE.format(entry_id=self.entry_id) + async_dispatcher_send(hass, signal, state) + + def async_update_device_state(self, hass: HomeAssistantType) -> None: + """Distribute an update of a core device state like availability.""" + signal = DISPATCHER_ON_DEVICE_UPDATE.format(entry_id=self.entry_id) + async_dispatcher_send(hass, signal) + + async def async_load_from_store(self) -> Tuple[List[EntityInfo], + List[UserService]]: + """Load the retained data from store and return de-serialized data.""" + restored = await self.store.async_load() + if restored is None: + return [], [] + + self.device_info = _attr_obj_from_dict(DeviceInfo, + **restored.pop('device_info')) + infos = [] + for comp_type, restored_infos in restored.items(): + if comp_type not in COMPONENT_TYPE_TO_INFO: + continue + for info in restored_infos: + cls = COMPONENT_TYPE_TO_INFO[comp_type] + infos.append(_attr_obj_from_dict(cls, **info)) + services = [] + for service in restored.get('services', []): + services.append(UserService.from_dict(service)) + return infos, services + + async def async_save_to_store(self) -> None: + """Generate dynamic data to store and save it to the filesystem.""" + store_data = { + 'device_info': attr.asdict(self.device_info), + 'services': [] + } + + for comp_type, infos in self.info.items(): + store_data[comp_type] = [attr.asdict(info) + for info in infos.values()] + for service in self.services.values(): + store_data['services'].append(service.to_dict()) + + await self.store.async_save(store_data) + + +def _attr_obj_from_dict(cls, **kwargs): + return cls(**{key: kwargs[key] for key in attr.fields_dict(cls) + if key in kwargs}) diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 50cf04203f3..255bdaa8cb1 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -1,6 +1,8 @@ """Support for ESPHome fans.""" import logging -from typing import TYPE_CHECKING, List, Optional +from typing import List, Optional + +from aioesphomeapi import FanInfo, FanSpeed, FanState from homeassistant.components.fan import ( SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_OSCILLATE, @@ -8,12 +10,9 @@ from homeassistant.components.fan import ( from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType -from . import EsphomeEntity, platform_async_setup_entry, \ - esphome_state_property, esphome_map_enum - -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import FanInfo, FanState, FanSpeed # noqa +from . import ( + EsphomeEntity, esphome_map_enum, esphome_state_property, + platform_async_setup_entry) _LOGGER = logging.getLogger(__name__) @@ -21,9 +20,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry, async_add_entities) -> None: """Set up ESPHome fans based on a config entry.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import FanInfo, FanState # noqa - await platform_async_setup_entry( hass, entry, async_add_entities, component_key='fan', @@ -34,8 +30,6 @@ async def async_setup_entry(hass: HomeAssistantType, @esphome_map_enum def _fan_speeds(): - # pylint: disable=redefined-outer-name - from aioesphomeapi import FanSpeed # noqa return { FanSpeed.LOW: SPEED_LOW, FanSpeed.MEDIUM: SPEED_MEDIUM, @@ -47,11 +41,11 @@ class EsphomeFan(EsphomeEntity, FanEntity): """A fan implementation for ESPHome.""" @property - def _static_info(self) -> 'FanInfo': + def _static_info(self) -> FanInfo: return super()._static_info @property - def _state(self) -> Optional['FanState']: + def _state(self) -> Optional[FanState]: return super()._state async def async_set_speed(self, speed: str) -> None: diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 6b4abafe62b..f94229d61cc 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -1,6 +1,8 @@ """Support for ESPHome lights.""" import logging -from typing import TYPE_CHECKING, List, Optional, Tuple +from typing import List, Optional, Tuple + +from aioesphomeapi import LightInfo, LightState from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, @@ -11,11 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType import homeassistant.util.color as color_util -from . import EsphomeEntity, platform_async_setup_entry, esphome_state_property - -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import LightInfo, LightState # noqa +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry _LOGGER = logging.getLogger(__name__) @@ -29,9 +27,6 @@ FLASH_LENGTHS = { async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry, async_add_entities) -> None: """Set up ESPHome lights based on a config entry.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import LightInfo, LightState # noqa - await platform_async_setup_entry( hass, entry, async_add_entities, component_key='light', @@ -44,11 +39,11 @@ class EsphomeLight(EsphomeEntity, Light): """A switch implementation for ESPHome.""" @property - def _static_info(self) -> 'LightInfo': + def _static_info(self) -> LightInfo: return super()._static_info @property - def _state(self) -> Optional['LightState']: + def _state(self) -> Optional[LightState]: return super()._state @esphome_state_property diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 9d25ec6d034..a986a864189 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -1,11 +1,13 @@ { "domain": "esphome", "name": "ESPHome", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/esphome", "requirements": [ - "aioesphomeapi==2.0.1" + "aioesphomeapi==2.1.0" ], "dependencies": [], + "zeroconf": ["_esphomelib._tcp.local."], "codeowners": [ "@OttoWinter" ] diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 8d8fb938c68..a5a530b49f1 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -1,17 +1,15 @@ """Support for esphome sensors.""" import logging import math -from typing import TYPE_CHECKING, Optional +from typing import Optional + +from aioesphomeapi import ( + SensorInfo, SensorState, TextSensorInfo, TextSensorState) from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType -from . import EsphomeEntity, platform_async_setup_entry, esphome_state_property - -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import ( # noqa - SensorInfo, SensorState, TextSensorInfo, TextSensorState) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry _LOGGER = logging.getLogger(__name__) @@ -19,10 +17,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry, async_add_entities) -> None: """Set up esphome sensors based on a config entry.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import ( # noqa - SensorInfo, SensorState, TextSensorInfo, TextSensorState) - await platform_async_setup_entry( hass, entry, async_add_entities, component_key='sensor', @@ -41,11 +35,11 @@ class EsphomeSensor(EsphomeEntity): """A sensor implementation for esphome.""" @property - def _static_info(self) -> 'SensorInfo': + def _static_info(self) -> SensorInfo: return super()._static_info @property - def _state(self) -> Optional['SensorState']: + def _state(self) -> Optional[SensorState]: return super()._state @property diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 8f691d9cb00..3b662441e13 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -26,9 +26,10 @@ }, "discovery_confirm": { "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?", - "title": "Discovered ESPHome node" + "title": "Discovered ESPHome node" } }, - "title": "ESPHome" + "title": "ESPHome", + "flow_title": "ESPHome: {name}" } } diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index 77994d0be58..d209df8cd83 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -1,16 +1,14 @@ """Support for ESPHome switches.""" import logging -from typing import TYPE_CHECKING, Optional +from typing import Optional + +from aioesphomeapi import SwitchInfo, SwitchState from homeassistant.components.switch import SwitchDevice from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType -from . import EsphomeEntity, platform_async_setup_entry, esphome_state_property - -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import SwitchInfo, SwitchState # noqa +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry _LOGGER = logging.getLogger(__name__) @@ -18,9 +16,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry, async_add_entities) -> None: """Set up ESPHome switches based on a config entry.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import SwitchInfo, SwitchState # noqa - await platform_async_setup_entry( hass, entry, async_add_entities, component_key='switch', @@ -33,11 +28,11 @@ class EsphomeSwitch(EsphomeEntity, SwitchDevice): """A switch implementation for ESPHome.""" @property - def _static_info(self) -> 'SwitchInfo': + def _static_info(self) -> SwitchInfo: return super()._static_info @property - def _state(self) -> Optional['SwitchState']: + def _state(self) -> Optional[SwitchState]: return super()._state @property diff --git a/homeassistant/components/essent/manifest.json b/homeassistant/components/essent/manifest.json index 49189f6bacb..41313cb44a9 100644 --- a/homeassistant/components/essent/manifest.json +++ b/homeassistant/components/essent/manifest.json @@ -2,7 +2,7 @@ "domain": "essent", "name": "Essent", "documentation": "https://www.home-assistant.io/components/essent", - "requirements": ["PyEssent==0.10"], + "requirements": ["PyEssent==0.12"], "dependencies": [], "codeowners": ["@TheLastProject"] } diff --git a/homeassistant/components/essent/sensor.py b/homeassistant/components/essent/sensor.py index 545ed3d5baf..e77b256abb7 100644 --- a/homeassistant/components/essent/sensor.py +++ b/homeassistant/components/essent/sensor.py @@ -36,6 +36,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): tariff, data['values']['LVR'][tariff]['unit'])) + if not meters: + hass.components.persistent_notification.create( + 'Couldn\'t find any meter readings. ' + 'Please ensure Verbruiks Manager is enabled in Mijn Essent ' + 'and at least one reading has been logged to Meterstanden.', + title='Essent', notification_id='essent_notification') + return + add_devices(meters, True) @@ -46,14 +54,13 @@ class EssentBase(): """Initialize the Essent API.""" self._username = username self._password = password - self._meters = [] self._meter_data = {} self.update() def retrieve_meters(self): """Retrieve the list of meters.""" - return self._meters + return self._meter_data.keys() def retrieve_meter_data(self, meter): """Retrieve the data for this meter.""" @@ -63,10 +70,12 @@ class EssentBase(): def update(self): """Retrieve the latest meter data from Essent.""" essent = PyEssent(self._username, self._password) - self._meters = essent.get_EANs() - for meter in self._meters: - self._meter_data[meter] = essent.read_meter( - meter, only_last_meter_reading=True) + eans = essent.get_EANs() + for possible_meter in eans: + meter_data = essent.read_meter( + possible_meter, only_last_meter_reading=True) + if meter_data: + self._meter_data[possible_meter] = meter_data class EssentMeter(Entity): diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py index 0e8a69e0bcf..20b4e538085 100644 --- a/homeassistant/components/ffmpeg/camera.py +++ b/homeassistant/components/ffmpeg/camera.py @@ -47,8 +47,7 @@ class FFmpegCamera(Camera): """Return supported features.""" return SUPPORT_STREAM - @property - def stream_source(self): + async def stream_source(self): """Return the stream source.""" return self._input.split(' ')[-1] @@ -59,7 +58,7 @@ class FFmpegCamera(Camera): image = await asyncio.shield(ffmpeg.get_image( self._input, output_format=IMAGE_JPEG, - extra_cmd=self._extra_arguments), loop=self.hass.loop) + extra_cmd=self._extra_arguments)) return image async def handle_async_mjpeg_stream(self, request): diff --git a/homeassistant/components/fitbit/manifest.json b/homeassistant/components/fitbit/manifest.json index baf0d8aaed1..6a6316d80a3 100644 --- a/homeassistant/components/fitbit/manifest.json +++ b/homeassistant/components/fitbit/manifest.json @@ -3,7 +3,7 @@ "name": "Fitbit", "documentation": "https://www.home-assistant.io/components/fitbit", "requirements": [ - "fitbit==0.3.0" + "fitbit==0.3.1" ], "dependencies": [ "configurator", diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py index 384bf26599a..93a478611db 100644 --- a/homeassistant/components/flock/notify.py +++ b/homeassistant/components/flock/notify.py @@ -26,15 +26,14 @@ async def get_service(hass, config, discovery_info=None): url = '{}{}'.format(_RESOURCE, access_token) session = async_get_clientsession(hass) - return FlockNotificationService(url, session, hass.loop) + return FlockNotificationService(url, session) class FlockNotificationService(BaseNotificationService): """Implement the notification service for Flock.""" - def __init__(self, url, session, loop): + def __init__(self, url, session): """Initialize the Flock notification service.""" - self._loop = loop self._url = url self._session = session @@ -45,7 +44,7 @@ class FlockNotificationService(BaseNotificationService): _LOGGER.debug("Attempting to call Flock at %s", self._url) try: - with async_timeout.timeout(10, loop=self._loop): + with async_timeout.timeout(10): response = await self._session.post(self._url, json=payload) result = await response.json() diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index f83c3f1966a..3bb000380d7 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -77,8 +77,7 @@ class FoscamCam(Camera): return SUPPORT_STREAM return 0 - @property - def stream_source(self): + async def stream_source(self): """Return the stream source.""" if self._rtsp_port: return 'rtsp://{}:{}@{}:{}/videoMain'.format( diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py index 1986c932e22..6125057ca33 100644 --- a/homeassistant/components/freedns/__init__.py +++ b/homeassistant/components/freedns/__init__.py @@ -68,7 +68,7 @@ async def _update_freedns(hass, session, url, auth_token): params[auth_token] = "" try: - with async_timeout.timeout(TIMEOUT, loop=hass.loop): + with async_timeout.timeout(TIMEOUT): resp = await session.get(url, params=params) body = await resp.text() diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 7ef031a90cb..a18ed6eb3d1 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -1,13 +1,13 @@ """Handle the frontend for Home Assistant.""" -import asyncio import json import logging import os import pathlib -from aiohttp import web +from aiohttp import web, web_urldispatcher, hdrs import voluptuous as vol import jinja2 +from yarl import URL import homeassistant.helpers.config_validation as cv from homeassistant.components.http.view import HomeAssistantView @@ -26,6 +26,7 @@ CONF_EXTRA_HTML_URL = 'extra_html_url' CONF_EXTRA_HTML_URL_ES5 = 'extra_html_url_es5' CONF_FRONTEND_REPO = 'development_repo' CONF_JS_VERSION = 'javascript_version' +EVENT_PANELS_UPDATED = 'panels_updated' DEFAULT_THEME_COLOR = '#03A9F4' @@ -50,7 +51,6 @@ for size in (192, 384, 512, 1024): 'type': 'image/png' }) -DATA_FINALIZE_PANEL = 'frontend_finalize_panel' DATA_PANELS = 'frontend_panels' DATA_JS_VERSION = 'frontend_js_version' DATA_EXTRA_HTML_URL = 'frontend_extra_html_url' @@ -128,15 +128,6 @@ class Panel: self.config = config self.require_admin = require_admin - @callback - def async_register_index_routes(self, router, index_view): - """Register routes for panel to be served by index view.""" - router.add_route( - 'get', '/{}'.format(self.frontend_url_path), index_view.get) - router.add_route( - 'get', '/{}/{{extra:.+}}'.format(self.frontend_url_path), - index_view.get) - @callback def to_response(self): """Panel as dictionary.""" @@ -151,26 +142,36 @@ class Panel: @bind_hass -async def async_register_built_in_panel(hass, component_name, - sidebar_title=None, sidebar_icon=None, - frontend_url_path=None, config=None, - require_admin=False): +@callback +def async_register_built_in_panel(hass, component_name, + sidebar_title=None, sidebar_icon=None, + frontend_url_path=None, config=None, + require_admin=False): """Register a built-in panel.""" panel = Panel(component_name, sidebar_title, sidebar_icon, frontend_url_path, config, require_admin) - panels = hass.data.get(DATA_PANELS) - if panels is None: - panels = hass.data[DATA_PANELS] = {} + panels = hass.data.setdefault(DATA_PANELS, {}) if panel.frontend_url_path in panels: _LOGGER.warning("Overwriting component %s", panel.frontend_url_path) - if DATA_FINALIZE_PANEL in hass.data: - hass.data[DATA_FINALIZE_PANEL](panel) - panels[panel.frontend_url_path] = panel + hass.bus.async_fire(EVENT_PANELS_UPDATED) + + +@bind_hass +@callback +def async_remove_panel(hass, frontend_url_path): + """Remove a built-in panel.""" + panel = hass.data.get(DATA_PANELS, {}).pop(frontend_url_path, None) + + if panel is None: + _LOGGER.warning("Removing unknown panel %s", frontend_url_path) + + hass.bus.async_fire(EVENT_PANELS_UPDATED) + @bind_hass @callback @@ -233,28 +234,14 @@ async def async_setup(hass, config): if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - index_view = IndexView(repo_path) - hass.http.register_view(index_view) + hass.http.app.router.register_resource(IndexView(repo_path, hass)) - @callback - def async_finalize_panel(panel): - """Finalize setup of a panel.""" - panel.async_register_index_routes(hass.http.app.router, index_view) + for panel in ('kiosk', 'states', 'profile'): + async_register_built_in_panel(hass, panel) - await asyncio.wait( - [async_register_built_in_panel(hass, panel) for panel in ( - 'kiosk', 'states', 'profile')], loop=hass.loop) - await asyncio.wait( - [async_register_built_in_panel(hass, panel, require_admin=True) - for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', - 'dev-template', 'dev-mqtt')], loop=hass.loop) - - hass.data[DATA_FINALIZE_PANEL] = async_finalize_panel - - # Finalize registration of panels that registered before frontend was setup - # This includes the built-in panels from line above. - for panel in hass.data[DATA_PANELS].values(): - async_finalize_panel(panel) + for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', + 'dev-template', 'dev-mqtt'): + async_register_built_in_panel(hass, panel, require_admin=True) if DATA_EXTRA_HTML_URL not in hass.data: hass.data[DATA_EXTRA_HTML_URL] = set() @@ -318,18 +305,64 @@ def _async_setup_themes(hass, themes): hass.services.async_register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes) -class IndexView(HomeAssistantView): +class IndexView(web_urldispatcher.AbstractResource): """Serve the frontend.""" - url = '/' - name = 'frontend:index' - requires_auth = False - - def __init__(self, repo_path): + def __init__(self, repo_path, hass): """Initialize the frontend view.""" + super().__init__(name="frontend:index") self.repo_path = repo_path + self.hass = hass self._template_cache = None + @property + def canonical(self) -> str: + """Return resource's canonical path.""" + return '/' + + @property + def _route(self): + """Return the index route.""" + return web_urldispatcher.ResourceRoute('GET', self.get, self) + + def url_for(self, **kwargs: str) -> URL: + """Construct url for resource with additional params.""" + return URL("/") + + async def resolve(self, request: web.Request): + """Resolve resource. + + Return (UrlMappingMatchInfo, allowed_methods) pair. + """ + if (request.path != '/' and + request.url.parts[1] not in self.hass.data[DATA_PANELS]): + return None, set() + + if request.method != hdrs.METH_GET: + return None, {'GET'} + + return web_urldispatcher.UrlMappingMatchInfo({}, self._route), {'GET'} + + def add_prefix(self, prefix: str) -> None: + """Add a prefix to processed URLs. + + Required for subapplications support. + """ + + def get_info(self): + """Return a dict with additional info useful for introspection.""" + return { + 'panels': list(self.hass.data[DATA_PANELS]) + } + + def freeze(self) -> None: + """Freeze the resource.""" + pass + + def raw_match(self, path: str) -> bool: + """Perform a raw match against path.""" + pass + def get_template(self): """Get template.""" tpl = self._template_cache @@ -345,8 +378,8 @@ class IndexView(HomeAssistantView): return tpl - async def get(self, request, extra=None): - """Serve the index view.""" + async def get(self, request: web.Request): + """Serve the index page for panel pages.""" hass = request.app['hass'] if not hass.components.onboarding.async_is_onboarded(): @@ -367,6 +400,14 @@ class IndexView(HomeAssistantView): content_type='text/html' ) + def __len__(self) -> int: + """Return length of resource.""" + return 1 + + def __iter__(self): + """Iterate over routes.""" + return iter([self._route]) + class ManifestJSONView(HomeAssistantView): """View to return a manifest.json.""" diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 45b1f0ff351..0d517aa6560 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==20190514.0" + "home-assistant-frontend==20190604.0" ], "dependencies": [ "api", @@ -15,6 +15,6 @@ "websocket_api" ], "codeowners": [ - "@home-assistant/core" + "@home-assistant/frontend" ] } diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index bfe42a5b080..8b98d84c06d 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -127,7 +127,7 @@ class GenericCamera(Camera): try: websession = async_get_clientsession( self.hass, verify_ssl=self.verify_ssl) - with async_timeout.timeout(10, loop=self.hass.loop): + with async_timeout.timeout(10): response = await websession.get( url, auth=self._auth) self._last_image = await response.read() @@ -146,7 +146,6 @@ class GenericCamera(Camera): """Return the name of this device.""" return self._name - @property - def stream_source(self): + async def stream_source(self): """Return the source of the stream.""" return self._stream_source diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index 181e61a7e48..b9ab1515d32 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -1,18 +1,25 @@ -"""This module connects to a Genius hub and shares the data.""" +"""Support for a Genius Hub system.""" +from datetime import timedelta import logging import voluptuous as vol +from geniushubclient import GeniusHubClient + from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval _LOGGER = logging.getLogger(__name__) DOMAIN = 'geniushub' +SCAN_INTERVAL = timedelta(seconds=60) + _V1_API_SCHEMA = vol.Schema({ vol.Required(CONF_TOKEN): cv.string, }) @@ -31,33 +38,50 @@ CONFIG_SCHEMA = vol.Schema({ async def async_setup(hass, hass_config): """Create a Genius Hub system.""" - from geniushubclient import GeniusHubClient # noqa; pylint: disable=no-name-in-module - - geniushub_data = hass.data[DOMAIN] = {} - kwargs = dict(hass_config[DOMAIN]) if CONF_HOST in kwargs: args = (kwargs.pop(CONF_HOST), ) else: args = (kwargs.pop(CONF_TOKEN), ) + hass.data[DOMAIN] = {} + data = hass.data[DOMAIN]['data'] = GeniusData(hass, args, kwargs) try: - client = geniushub_data['client'] = GeniusHubClient( - *args, **kwargs, session=async_get_clientsession(hass) - ) - - await client.hub.update() - + await data._client.hub.update() # pylint: disable=protected-access except AssertionError: # assert response.status == HTTP_OK _LOGGER.warning( - "setup(): Failed, check your configuration.", + "Setup failed, check your configuration.", exc_info=True) return False - hass.async_create_task(async_load_platform( - hass, 'climate', DOMAIN, {}, hass_config)) + async_track_time_interval(hass, data.async_update, SCAN_INTERVAL) - hass.async_create_task(async_load_platform( - hass, 'water_heater', DOMAIN, {}, hass_config)) + for platform in ['climate', 'water_heater']: + hass.async_create_task(async_load_platform( + hass, platform, DOMAIN, {}, hass_config)) + + if not data._client._api_v1: # pylint: disable=protected-access + for platform in ['sensor', 'binary_sensor']: + hass.async_create_task(async_load_platform( + hass, platform, DOMAIN, {}, hass_config)) return True + + +class GeniusData: + """Container for geniushub client and data.""" + + def __init__(self, hass, args, kwargs): + """Initialize the geniushub client.""" + self._hass = hass + self._client = hass.data[DOMAIN]['client'] = GeniusHubClient( + *args, **kwargs, session=async_get_clientsession(hass)) + + async def async_update(self, now, **kwargs): + """Update the geniushub client's data.""" + try: + await self._client.hub.update() + except AssertionError: # assert response.status == HTTP_OK + _LOGGER.warning("Update failed.", exc_info=True) + return + async_dispatcher_send(self._hass, DOMAIN) diff --git a/homeassistant/components/geniushub/binary_sensor.py b/homeassistant/components/geniushub/binary_sensor.py new file mode 100644 index 00000000000..cbea4147e73 --- /dev/null +++ b/homeassistant/components/geniushub/binary_sensor.py @@ -0,0 +1,74 @@ +"""Support for Genius Hub binary_sensor devices.""" +from datetime import datetime +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +GH_IS_SWITCH = ['Dual Channel Receiver', 'Electric Switch', 'Smart Plug'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Genius Hub sensor entities.""" + client = hass.data[DOMAIN]['client'] + + switches = [GeniusBinarySensor(client, d) + for d in client.hub.device_objs if d.type[:21] in GH_IS_SWITCH] + + async_add_entities(switches) + + +class GeniusBinarySensor(BinarySensorDevice): + """Representation of a Genius Hub binary_sensor.""" + + def __init__(self, client, device): + """Initialize the binary sensor.""" + self._client = client + self._device = device + + if device.type[:21] == 'Dual Channel Receiver': + self._name = 'Dual Channel Receiver {}'.format(device.id) + else: + self._name = '{} {}'.format(device.type, device.id) + + 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 should_poll(self) -> bool: + """Return False as the geniushub devices should not be polled.""" + return False + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._device.state['outputOnOff'] + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attrs = {} + attrs['assigned_zone'] = self._device.assignedZones[0]['name'] + + last_comms = self._device._info_raw['childValues']['lastComms']['val'] # noqa; pylint: disable=protected-access + if last_comms != 0: + attrs['last_comms'] = datetime.utcfromtimestamp( + last_comms).isoformat() + + return {**attrs} diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index b396f8d6dac..22761f6b184 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -1,5 +1,4 @@ """Support for Genius Hub climate devices.""" -import asyncio import logging from homeassistant.components.climate import ClimateDevice @@ -7,30 +6,33 @@ from homeassistant.components.climate.const import ( STATE_AUTO, STATE_ECO, STATE_HEAT, STATE_MANUAL, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_ON_OFF) from homeassistant.const import ( - ATTR_TEMPERATURE, TEMP_CELSIUS) + ATTR_TEMPERATURE, STATE_OFF, TEMP_CELSIUS) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import DOMAIN _LOGGER = logging.getLogger(__name__) -GH_CLIMATE_DEVICES = ['radiator'] +GH_ZONES = ['radiator'] -GENIUSHUB_SUPPORT_FLAGS = \ +GH_SUPPORT_FLAGS = \ SUPPORT_TARGET_TEMPERATURE | \ SUPPORT_ON_OFF | \ SUPPORT_OPERATION_MODE -GENIUSHUB_MAX_TEMP = 28.0 -GENIUSHUB_MIN_TEMP = 4.0 +GH_MAX_TEMP = 28.0 +GH_MIN_TEMP = 4.0 # Genius Hub Zones support only Off, Override/Boost, Footprint & Timer modes HA_OPMODE_TO_GH = { + STATE_OFF: 'off', STATE_AUTO: 'timer', STATE_ECO: 'footprint', STATE_MANUAL: 'override', } -GH_OPMODE_OFF = 'off' GH_STATE_TO_HA = { + 'off': STATE_OFF, 'timer': STATE_AUTO, 'footprint': STATE_ECO, 'away': None, @@ -39,10 +41,9 @@ GH_STATE_TO_HA = { 'test': None, 'linked': None, 'other': None, -} # intentionally missing 'off': None - +} # temperature is repeated here, as it gives access to high-precision temps -GH_DEVICE_STATE_ATTRS = ['temperature', 'type', 'occupied', 'override'] +GH_STATE_ATTRS = ['temperature', 'type', 'occupied', 'override'] async def async_setup_platform(hass, hass_config, async_add_entities, @@ -50,60 +51,73 @@ async def async_setup_platform(hass, hass_config, async_add_entities, """Set up the Genius Hub climate entities.""" client = hass.data[DOMAIN]['client'] - entities = [GeniusClimate(client, z) - for z in client.hub.zone_objs if z.type in GH_CLIMATE_DEVICES] - - async_add_entities(entities) + async_add_entities([GeniusClimateZone(client, z) + for z in client.hub.zone_objs if z.type in GH_ZONES]) -class GeniusClimate(ClimateDevice): +class GeniusClimateZone(ClimateDevice): """Representation of a Genius Hub climate device.""" def __init__(self, client, zone): """Initialize the climate device.""" self._client = client - self._objref = zone - self._id = zone.id - self._name = zone.name + self._zone = zone # Only some zones have movement detectors, which allows footprint mode op_list = list(HA_OPMODE_TO_GH) - if not hasattr(self._objref, 'occupied'): + if not hasattr(self._zone, 'occupied'): op_list.remove(STATE_ECO) self._operation_list = op_list + self._supported_features = GH_SUPPORT_FLAGS + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + 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 climate device.""" - return self._objref.name + return self._zone.name @property def device_state_attributes(self): """Return the device state attributes.""" - tmp = self._objref.__dict__.items() - state = {k: v for k, v in tmp if k in GH_DEVICE_STATE_ATTRS} + tmp = self._zone.__dict__.items() + return {'status': {k: v for k, v in tmp if k in GH_STATE_ATTRS}} - return {'status': state} + @property + def should_poll(self) -> bool: + """Return False as the geniushub devices should not be polled.""" + return False + + @property + def icon(self): + """Return the icon to use in the frontend UI.""" + return "mdi:radiator" @property def current_temperature(self): """Return the current temperature.""" - return self._objref.temperature + return self._zone.temperature @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._objref.setpoint + return self._zone.setpoint @property def min_temp(self): """Return max valid temperature that can be set.""" - return GENIUSHUB_MIN_TEMP + return GH_MIN_TEMP @property def max_temp(self): """Return max valid temperature that can be set.""" - return GENIUSHUB_MAX_TEMP + return GH_MAX_TEMP @property def temperature_unit(self): @@ -113,7 +127,7 @@ class GeniusClimate(ClimateDevice): @property def supported_features(self): """Return the list of supported features.""" - return GENIUSHUB_SUPPORT_FLAGS + return self._supported_features @property def operation_list(self): @@ -123,34 +137,30 @@ class GeniusClimate(ClimateDevice): @property def current_operation(self): """Return the current operation mode.""" - return GH_STATE_TO_HA.get(self._objref.mode) + return GH_STATE_TO_HA[self._zone.mode] @property def is_on(self): """Return True if the device is on.""" - return self._objref.mode in GH_STATE_TO_HA + return self._zone.mode != HA_OPMODE_TO_GH[STATE_OFF] async def async_set_operation_mode(self, operation_mode): """Set a new operation mode for this zone.""" - await self._objref.set_mode(HA_OPMODE_TO_GH.get(operation_mode)) + await self._zone.set_mode(HA_OPMODE_TO_GH[operation_mode]) async def async_set_temperature(self, **kwargs): """Set a new target temperature for this zone.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - await self._objref.set_override(temperature, 3600) # 1 hour + await self._zone.set_override(kwargs.get(ATTR_TEMPERATURE), 3600) async def async_turn_on(self): - """Turn on this heating zone.""" - await self._objref.set_mode(HA_OPMODE_TO_GH.get(STATE_AUTO)) + """Turn on this heating zone. + + Set a Zone to Footprint mode if they have a Room sensor, and to Timer + mode otherwise. + """ + mode = STATE_ECO if hasattr(self._zone, 'occupied') else STATE_AUTO + await self._zone.set_mode(HA_OPMODE_TO_GH[mode]) async def async_turn_off(self): """Turn off this heating zone (i.e. to frost protect).""" - await self._objref.set_mode(GH_OPMODE_OFF) - - async def async_update(self): - """Get the latest data from the hub.""" - try: - await self._objref.update() - except (AssertionError, asyncio.TimeoutError) as err: - _LOGGER.warning("Update for %s failed, message: %s", - self._id, err) + await self._zone.set_mode(HA_OPMODE_TO_GH[STATE_OFF]) diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json index 99449211a7d..b2c7286a2d5 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.6" + "geniushub-client==0.4.11" ], "dependencies": [], "codeowners": ["@zxdavb"] diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py new file mode 100644 index 00000000000..ef148b48143 --- /dev/null +++ b/homeassistant/components/geniushub/sensor.py @@ -0,0 +1,136 @@ +"""Support for Genius Hub sensor devices.""" +from datetime import datetime +import logging + +from homeassistant.const import DEVICE_CLASS_BATTERY +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +GH_HAS_BATTERY = [ + 'Room Thermostat', 'Genius Valve', 'Room Sensor', 'Radiator Valve'] + +GH_LEVEL_MAPPING = { + 'error': 'Errors', + 'warning': 'Warnings', + 'information': 'Information' +} + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Genius Hub sensor entities.""" + client = hass.data[DOMAIN]['client'] + + sensors = [GeniusDevice(client, d) + for d in client.hub.device_objs if d.type in GH_HAS_BATTERY] + + issues = [GeniusIssue(client, i) + for i in list(GH_LEVEL_MAPPING)] + + async_add_entities(sensors + issues, update_before_add=True) + + +class GeniusDevice(Entity): + """Representation of a Genius Hub sensor.""" + + def __init__(self, client, device): + """Initialize the sensor.""" + self._client = client + self._device = device + + self._name = '{} {}'.format(device.type, device.id) + + 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 DEVICE_CLASS_BATTERY + + @property + def unit_of_measurement(self): + """Return the unit of measurement of the sensor.""" + return '%' + + @property + def should_poll(self) -> bool: + """Return False as the geniushub devices should not be polled.""" + return False + + @property + def state(self): + """Return the state of the sensor.""" + level = self._device.state['batteryLevel'] + return level if level != 255 else 0 + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attrs = {} + attrs['assigned_zone'] = self._device.assignedZones[0]['name'] + + last_comms = self._device._info_raw['childValues']['lastComms']['val'] # noqa; pylint: disable=protected-access + attrs['last_comms'] = datetime.utcfromtimestamp( + last_comms).isoformat() + + return {**attrs} + + +class GeniusIssue(Entity): + """Representation of a Genius Hub sensor.""" + + def __init__(self, client, level): + """Initialize the sensor.""" + self._hub = client.hub + self._name = GH_LEVEL_MAPPING[level] + self._level = level + self._issues = [] + + 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 should_poll(self) -> bool: + """Return False as the geniushub devices should not be polled.""" + return False + + @property + def state(self): + """Return the number of issues.""" + return len(self._issues) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return {'{}_list'.format(self._level): self._issues} + + async def async_update(self): + """Process the sensor's state data.""" + self._issues = [i['description'] + for i in self._hub.issues if i['level'] == self._level] diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py index f5f09f9b1d5..6efbed514ee 100644 --- a/homeassistant/components/geniushub/water_heater.py +++ b/homeassistant/components/geniushub/water_heater.py @@ -1,5 +1,4 @@ """Support for Genius Hub water_heater devices.""" -import asyncio import logging from homeassistant.components.water_heater import ( @@ -7,6 +6,8 @@ from homeassistant.components.water_heater import ( SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) from homeassistant.const import ( ATTR_TEMPERATURE, STATE_OFF, TEMP_CELSIUS) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import DOMAIN @@ -15,15 +16,15 @@ STATE_MANUAL = 'manual' _LOGGER = logging.getLogger(__name__) -GH_WATER_HEATERS = ['hot water temperature'] +GH_HEATERS = ['hot water temperature'] -GENIUSHUB_SUPPORT_FLAGS = \ +GH_SUPPORT_FLAGS = \ SUPPORT_TARGET_TEMPERATURE | \ SUPPORT_OPERATION_MODE # HA does not have SUPPORT_ON_OFF for water_heater -GENIUSHUB_MAX_TEMP = 80.0 -GENIUSHUB_MIN_TEMP = 30.0 +GH_MAX_TEMP = 80.0 +GH_MIN_TEMP = 30.0 # Genius Hub HW supports only Off, Override/Boost & Timer modes HA_OPMODE_TO_GH = { @@ -31,7 +32,6 @@ HA_OPMODE_TO_GH = { STATE_AUTO: 'timer', STATE_MANUAL: 'override', } -GH_OPMODE_OFF = 'off' GH_STATE_TO_HA = { 'off': STATE_OFF, 'timer': STATE_AUTO, @@ -43,8 +43,7 @@ GH_STATE_TO_HA = { 'linked': None, 'other': None, } - -GH_DEVICE_STATE_ATTRS = ['type', 'override'] +GH_STATE_ATTRS = ['type', 'override'] async def async_setup_platform(hass, hass_config, async_add_entities, @@ -53,7 +52,7 @@ async def async_setup_platform(hass, hass_config, async_add_entities, client = hass.data[DOMAIN]['client'] entities = [GeniusWaterHeater(client, z) - for z in client.hub.zone_objs if z.type in GH_WATER_HEATERS] + for z in client.hub.zone_objs if z.type in GH_HEATERS] async_add_entities(entities) @@ -65,11 +64,17 @@ class GeniusWaterHeater(WaterHeaterDevice): """Initialize the water_heater device.""" self._client = client self._boiler = boiler - self._id = boiler.id - self._name = boiler.name self._operation_list = list(HA_OPMODE_TO_GH) + async def async_added_to_hass(self): + """Run when entity about to be added.""" + 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 water_heater device.""" @@ -79,9 +84,12 @@ class GeniusWaterHeater(WaterHeaterDevice): def device_state_attributes(self): """Return the device state attributes.""" tmp = self._boiler.__dict__.items() - state = {k: v for k, v in tmp if k in GH_DEVICE_STATE_ATTRS} + return {'status': {k: v for k, v in tmp if k in GH_STATE_ATTRS}} - return {'status': state} + @property + def should_poll(self) -> bool: + """Return False as the geniushub devices should not be polled.""" + return False @property def current_temperature(self): @@ -96,12 +104,12 @@ class GeniusWaterHeater(WaterHeaterDevice): @property def min_temp(self): """Return max valid temperature that can be set.""" - return GENIUSHUB_MIN_TEMP + return GH_MIN_TEMP @property def max_temp(self): """Return max valid temperature that can be set.""" - return GENIUSHUB_MAX_TEMP + return GH_MAX_TEMP @property def temperature_unit(self): @@ -111,7 +119,7 @@ class GeniusWaterHeater(WaterHeaterDevice): @property def supported_features(self): """Return the list of supported features.""" - return GENIUSHUB_SUPPORT_FLAGS + return GH_SUPPORT_FLAGS @property def operation_list(self): @@ -121,21 +129,13 @@ class GeniusWaterHeater(WaterHeaterDevice): @property def current_operation(self): """Return the current operation mode.""" - return GH_STATE_TO_HA.get(self._boiler.mode) + return GH_STATE_TO_HA[self._boiler.mode] async def async_set_operation_mode(self, operation_mode): """Set a new operation mode for this boiler.""" - await self._boiler.set_mode(HA_OPMODE_TO_GH.get(operation_mode)) + await self._boiler.set_mode(HA_OPMODE_TO_GH[operation_mode]) async def async_set_temperature(self, **kwargs): """Set a new target temperature for this boiler.""" temperature = kwargs[ATTR_TEMPERATURE] await self._boiler.set_override(temperature, 3600) # 1 hour - - async def async_update(self): - """Get the latest data from the hub.""" - try: - await self._boiler.update() - except (AssertionError, asyncio.TimeoutError) as err: - _LOGGER.warning("Update for %s failed, message: %s", - self._id, err) diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index 57eaf5393ae..944879788de 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -12,10 +12,11 @@ from homeassistant.helpers import config_entry_flow import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import slugify +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) -DOMAIN = 'geofency' CONF_MOBILE_BEACONS = 'mobile_beacons' CONFIG_SCHEMA = vol.Schema({ @@ -62,7 +63,11 @@ async def async_setup(hass, hass_config): """Set up the Geofency component.""" config = hass_config.get(DOMAIN, {}) mobile_beacons = config.get(CONF_MOBILE_BEACONS, []) - hass.data[DOMAIN] = [slugify(beacon) for beacon in mobile_beacons] + hass.data[DOMAIN] = { + 'beacons': [slugify(beacon) for beacon in mobile_beacons], + 'devices': set(), + 'unsub_device_tracker': {} + } return True @@ -76,7 +81,7 @@ async def handle_webhook(hass, webhook_id, request): status=HTTP_UNPROCESSABLE_ENTITY ) - if _is_mobile_beacon(data, hass.data[DOMAIN]): + if _is_mobile_beacon(data, hass.data[DOMAIN]['beacons']): return _set_location(hass, data, None) if data['entry'] == LOCATION_ENTRY: location_name = data['name'] @@ -127,19 +132,10 @@ async def async_setup_entry(hass, entry): async def async_unload_entry(hass, entry): """Unload a config entry.""" hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) - + hass.data[DOMAIN]['unsub_device_tracker'].pop(entry.entry_id)() await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER) return True # pylint: disable=invalid-name async_remove_entry = config_entry_flow.webhook_async_remove_entry - - -config_entry_flow.register_webhook_flow( - DOMAIN, - 'Geofency Webhook', - { - 'docs_url': 'https://www.home-assistant.io/components/geofency/' - } -) diff --git a/homeassistant/components/geofency/config_flow.py b/homeassistant/components/geofency/config_flow.py new file mode 100644 index 00000000000..422343b16bb --- /dev/null +++ b/homeassistant/components/geofency/config_flow.py @@ -0,0 +1,12 @@ +"""Config flow for Geofency.""" +from homeassistant.helpers import config_entry_flow +from .const import DOMAIN + + +config_entry_flow.register_webhook_flow( + DOMAIN, + 'Geofency Webhook', + { + 'docs_url': 'https://www.home-assistant.io/components/geofency/' + } +) diff --git a/homeassistant/components/geofency/const.py b/homeassistant/components/geofency/const.py new file mode 100644 index 00000000000..f42fb97f168 --- /dev/null +++ b/homeassistant/components/geofency/const.py @@ -0,0 +1,3 @@ +"""Const for Geofency.""" + +DOMAIN = 'geofency' diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index abccf610f5e..f9a7df638eb 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -1,35 +1,148 @@ """Support for the Geofency device tracker platform.""" import logging -from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN) +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, +) +from homeassistant.core import callback +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import ( + DeviceTrackerEntity +) from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers import device_registry -from . import DOMAIN as GEOFENCY_DOMAIN, TRACKER_UPDATE +from . import DOMAIN as GF_DOMAIN, TRACKER_UPDATE _LOGGER = logging.getLogger(__name__) -DATA_KEY = '{}.{}'.format(GEOFENCY_DOMAIN, DEVICE_TRACKER_DOMAIN) - -async def async_setup_entry(hass, entry, async_see): - """Configure a dispatcher connection based on a config entry.""" - async def _set_location(device, gps, location_name, attributes): +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Geofency config entry.""" + @callback + def _receive_data(device, gps, location_name, attributes): """Fire HA event to set location.""" - await async_see( - dev_id=device, - gps=gps, - location_name=location_name, - attributes=attributes - ) + if device in hass.data[GF_DOMAIN]['devices']: + return + + hass.data[GF_DOMAIN]['devices'].add(device) + + async_add_entities([GeofencyEntity( + device, gps, location_name, attributes + )]) + + hass.data[GF_DOMAIN]['unsub_device_tracker'][config_entry.entry_id] = \ + async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) + + # Restore previously loaded devices + dev_reg = await device_registry.async_get_registry(hass) + dev_ids = { + identifier[1] + for device in dev_reg.devices.values() + for identifier in device.identifiers + if identifier[0] == GF_DOMAIN + } + + if dev_ids: + hass.data[GF_DOMAIN]['devices'].update(dev_ids) + async_add_entities(GeofencyEntity(dev_id) for dev_id in dev_ids) - hass.data[DATA_KEY] = async_dispatcher_connect( - hass, TRACKER_UPDATE, _set_location - ) return True -async def async_unload_entry(hass, entry): - """Unload the config entry and remove the dispatcher connection.""" - hass.data[DATA_KEY]() - return True +class GeofencyEntity(DeviceTrackerEntity, RestoreEntity): + """Represent a tracked device.""" + + def __init__(self, device, gps=None, location_name=None, attributes=None): + """Set up Geofency entity.""" + self._attributes = attributes or {} + self._name = device + self._location_name = location_name + self._gps = gps + self._unsub_dispatcher = None + self._unique_id = device + + @property + def device_state_attributes(self): + """Return device specific attributes.""" + return self._attributes + + @property + def latitude(self): + """Return latitude value of the device.""" + return self._gps[0] + + @property + def longitude(self): + """Return longitude value of the device.""" + return self._gps[1] + + @property + def location_name(self): + """Return a location name for the current location of the device.""" + return self._location_name + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def unique_id(self): + """Return the unique ID.""" + return self._unique_id + + @property + def device_info(self): + """Return the device info.""" + return { + 'name': self._name, + 'identifiers': {(GF_DOMAIN, self._unique_id)}, + } + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + async def async_added_to_hass(self): + """Register state update callback.""" + await super().async_added_to_hass() + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, TRACKER_UPDATE, self._async_receive_data) + + if self._attributes: + return + + state = await self.async_get_last_state() + + if state is None: + self._gps = (None, None) + return + + attr = state.attributes + self._gps = (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)) + + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + await super().async_will_remove_from_hass() + self._unsub_dispatcher() + self.hass.data[GF_DOMAIN]['devices'].remove(self._unique_id) + + @callback + def _async_receive_data(self, device, gps, location_name, attributes): + """Mark the device as seen.""" + if device != self.name: + return + + self._attributes.update(attributes) + self._location_name = location_name + self._gps = gps + self.async_write_ha_state() diff --git a/homeassistant/components/geofency/manifest.json b/homeassistant/components/geofency/manifest.json index 576d0e419a7..d593aec46a4 100644 --- a/homeassistant/components/geofency/manifest.json +++ b/homeassistant/components/geofency/manifest.json @@ -1,6 +1,7 @@ { "domain": "geofency", "name": "Geofency", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/geofency", "requirements": [], "dependencies": [ diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index c8078b7d9d2..1e0ac6d9363 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -65,7 +65,7 @@ async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): """Handle request sync service calls.""" websession = async_get_clientsession(hass) try: - with async_timeout.timeout(15, loop=hass.loop): + with async_timeout.timeout(15): agent_user_id = call.data.get('agent_user_id') or \ call.context.user_id res = await websession.post( diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 92afe90a5ac..ebded79447e 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -12,6 +12,7 @@ from homeassistant.components import ( media_player, scene, script, + sensor, switch, vacuum, ) @@ -108,6 +109,7 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = { (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_WINDOW): TYPE_SENSOR, (media_player.DOMAIN, media_player.DEVICE_CLASS_TV): TYPE_TV, (media_player.DOMAIN, media_player.DEVICE_CLASS_SPEAKER): TYPE_SPEAKER, + (sensor.DOMAIN, sensor.DEVICE_CLASS_TEMPERATURE): TYPE_SENSOR, } CHALLENGE_ACK_NEEDED = 'ackNeeded' diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 4d3f2855b31..770a502ad5d 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -1,17 +1,18 @@ """Helper classes for Google Assistant integration.""" from asyncio import gather from collections.abc import Mapping +from typing import List from homeassistant.core import Context, callback from homeassistant.const import ( CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES, - ATTR_DEVICE_CLASS + ATTR_DEVICE_CLASS, CLOUD_NEVER_EXPOSED_ENTITIES ) from . import trait from .const import ( DOMAIN_TO_GOOGLE_TYPES, CONF_ALIASES, ERR_FUNCTION_NOT_SUPPORTED, - DEVICE_CLASS_TO_GOOGLE_TYPES, CONF_ROOM_HINT, + DEVICE_CLASS_TO_GOOGLE_TYPES, CONF_ROOM_HINT ) from .error import SmartHomeError @@ -21,15 +22,20 @@ class Config: def __init__(self, should_expose, entity_config=None, secure_devices_pin=None, - agent_user_id=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 # Agent User Id to use for query responses self.agent_user_id = agent_user_id + def should_2fa(self, state): + """If an entity should have 2FA checked.""" + return self._should_2fa is None or self._should_2fa(state) + class RequestData: """Hold data associated with a particular request.""" @@ -79,6 +85,22 @@ class GoogleEntity: if Trait.supported(domain, features, device_class)] return self._traits + @callback + def is_supported(self) -> bool: + """Return if the entity is supported by Google.""" + return self.state.state != STATE_UNAVAILABLE and bool(self.traits()) + + @callback + def might_2fa(self) -> bool: + """Return if the entity might encounter 2FA.""" + state = self.state + domain = state.domain + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + return any(trait.might_2fa(domain, features, device_class) + for trait in self.traits()) + async def sync_serialize(self): """Serialize entity for a SYNC response. @@ -86,27 +108,13 @@ class GoogleEntity: """ state = self.state - # When a state is unavailable, the attributes that describe - # capabilities will be stripped. For example, a light entity will miss - # the min/max mireds. Therefore they will be excluded from a sync. - if state.state == STATE_UNAVAILABLE: - return None - entity_config = self.config.entity_config.get(state.entity_id, {}) name = (entity_config.get(CONF_NAME) or state.name).strip() domain = state.domain device_class = state.attributes.get(ATTR_DEVICE_CLASS) - # If an empty string - if not name: - return None - traits = self.traits() - # Found no supported traits for this entity - if not traits: - return None - device_type = get_google_type(domain, device_class) @@ -213,3 +221,19 @@ def deep_update(target, source): else: target[key] = value return target + + +@callback +def async_get_entities(hass, config) -> List[GoogleEntity]: + """Return all entities that are supported by Google.""" + entities = [] + for state in hass.states.async_all(): + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + continue + + entity = GoogleEntity(hass, config, state) + + if entity.is_supported(): + entities.append(entity) + + return entities diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 1ec47bbedd6..07548ee95eb 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -1,17 +1,17 @@ """Support for Google Assistant Smart Home API.""" +import asyncio from itertools import product import logging from homeassistant.util.decorator import Registry -from homeassistant.const import ( - CLOUD_NEVER_EXPOSED_ENTITIES, ATTR_ENTITY_ID) +from homeassistant.const import ATTR_ENTITY_ID from .const import ( ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, ERR_UNKNOWN_ERROR, EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED, EVENT_QUERY_RECEIVED ) -from .helpers import RequestData, GoogleEntity +from .helpers import RequestData, GoogleEntity, async_get_entities from .error import SmartHomeError HANDLERS = Registry() @@ -81,22 +81,11 @@ async def async_devices_sync(hass, data, payload): {'request_id': data.request_id}, context=data.context) - devices = [] - for state in hass.states.async_all(): - if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: - continue - - if not data.config.should_expose(state): - continue - - entity = GoogleEntity(hass, data.config, state) - serialized = await entity.sync_serialize() - - if serialized is None: - _LOGGER.debug("No mapping for %s domain", entity.state) - continue - - devices.append(serialized) + devices = await asyncio.gather(*[ + entity.sync_serialize() for entity in + async_get_entities(hass, data.config) + if data.config.should_expose(entity.state) + ]) response = { 'agentUserId': data.config.agent_user_id or data.context.user_id, diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index cb2bf688ad0..7776daf65c9 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -13,6 +13,7 @@ from homeassistant.components import ( lock, scene, script, + sensor, switch, vacuum, ) @@ -104,6 +105,11 @@ class _Trait: commands = [] + @staticmethod + def might_2fa(domain, features, device_class): + """Return if the trait might ask for 2FA.""" + return False + def __init__(self, hass, state, config): """Initialize a trait for a state.""" self.hass = hass @@ -545,89 +551,126 @@ class TemperatureSettingTrait(_Trait): @staticmethod def supported(domain, features, device_class): """Test if state is supported.""" - if domain != climate.DOMAIN: - return False + if domain == climate.DOMAIN: + return features & climate.SUPPORT_OPERATION_MODE - return features & climate.SUPPORT_OPERATION_MODE + return (domain == sensor.DOMAIN + and device_class == sensor.DEVICE_CLASS_TEMPERATURE) def sync_attributes(self): """Return temperature point and modes attributes for a sync request.""" - modes = [] - supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) + response = {} + attrs = self.state.attributes + domain = self.state.domain + response['thermostatTemperatureUnit'] = _google_temp_unit( + self.hass.config.units.temperature_unit) - if supported & climate.SUPPORT_ON_OFF != 0: - modes.append(STATE_OFF) - modes.append(STATE_ON) + if domain == sensor.DOMAIN: + device_class = attrs.get(ATTR_DEVICE_CLASS) + if device_class == sensor.DEVICE_CLASS_TEMPERATURE: + response["queryOnlyTemperatureSetting"] = True - if supported & climate.SUPPORT_OPERATION_MODE != 0: - for mode in self.state.attributes.get(climate.ATTR_OPERATION_LIST, - []): - google_mode = self.hass_to_google.get(mode) - if google_mode and google_mode not in modes: - modes.append(google_mode) + elif domain == climate.DOMAIN: + modes = [] + supported = attrs.get(ATTR_SUPPORTED_FEATURES) - return { - 'availableThermostatModes': ','.join(modes), - 'thermostatTemperatureUnit': _google_temp_unit( - self.hass.config.units.temperature_unit) - } + if supported & climate.SUPPORT_ON_OFF != 0: + modes.append(STATE_OFF) + modes.append(STATE_ON) + + if supported & climate.SUPPORT_OPERATION_MODE != 0: + for mode in attrs.get(climate.ATTR_OPERATION_LIST, []): + google_mode = self.hass_to_google.get(mode) + if google_mode and google_mode not in modes: + modes.append(google_mode) + response['availableThermostatModes'] = ','.join(modes) + + return response def query_attributes(self): """Return temperature point and modes query attributes.""" - attrs = self.state.attributes response = {} - - operation = attrs.get(climate.ATTR_OPERATION_MODE) - supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) - - if (supported & climate.SUPPORT_ON_OFF - and self.state.state == STATE_OFF): - response['thermostatMode'] = 'off' - elif (supported & climate.SUPPORT_OPERATION_MODE and - operation in self.hass_to_google): - response['thermostatMode'] = self.hass_to_google[operation] - elif supported & climate.SUPPORT_ON_OFF: - response['thermostatMode'] = 'on' - + attrs = self.state.attributes + domain = self.state.domain unit = self.hass.config.units.temperature_unit + if domain == sensor.DOMAIN: + device_class = attrs.get(ATTR_DEVICE_CLASS) + if device_class == sensor.DEVICE_CLASS_TEMPERATURE: + current_temp = self.state.state + if current_temp is not None: + response['thermostatTemperatureAmbient'] = \ + round(temp_util.convert( + float(current_temp), + unit, + TEMP_CELSIUS + ), 1) - current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE) - if current_temp is not None: - response['thermostatTemperatureAmbient'] = \ - round(temp_util.convert(current_temp, unit, TEMP_CELSIUS), 1) + elif domain == climate.DOMAIN: + operation = attrs.get(climate.ATTR_OPERATION_MODE) + supported = attrs.get(ATTR_SUPPORTED_FEATURES) - current_humidity = attrs.get(climate.ATTR_CURRENT_HUMIDITY) - if current_humidity is not None: - response['thermostatHumidityAmbient'] = current_humidity + if (supported & climate.SUPPORT_ON_OFF + and self.state.state == STATE_OFF): + response['thermostatMode'] = 'off' + elif (supported & climate.SUPPORT_OPERATION_MODE + and operation in self.hass_to_google): + response['thermostatMode'] = self.hass_to_google[operation] + elif supported & climate.SUPPORT_ON_OFF: + response['thermostatMode'] = 'on' - if operation == climate.STATE_AUTO: - if (supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH and - supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW): - response['thermostatTemperatureSetpointHigh'] = \ + current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE) + if current_temp is not None: + response['thermostatTemperatureAmbient'] = \ round(temp_util.convert( - attrs[climate.ATTR_TARGET_TEMP_HIGH], - unit, TEMP_CELSIUS), 1) - response['thermostatTemperatureSetpointLow'] = \ - round(temp_util.convert( - attrs[climate.ATTR_TARGET_TEMP_LOW], - unit, TEMP_CELSIUS), 1) + current_temp, + unit, + TEMP_CELSIUS + ), 1) + + current_humidity = attrs.get(climate.ATTR_CURRENT_HUMIDITY) + if current_humidity is not None: + response['thermostatHumidityAmbient'] = current_humidity + + if operation == climate.STATE_AUTO: + if (supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH and + supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW): + response['thermostatTemperatureSetpointHigh'] = \ + round(temp_util.convert( + attrs[climate.ATTR_TARGET_TEMP_HIGH], + unit, TEMP_CELSIUS), 1) + response['thermostatTemperatureSetpointLow'] = \ + round(temp_util.convert( + attrs[climate.ATTR_TARGET_TEMP_LOW], + unit, TEMP_CELSIUS), 1) + else: + target_temp = attrs.get(ATTR_TEMPERATURE) + if target_temp is not None: + target_temp = round( + temp_util.convert( + target_temp, + unit, + TEMP_CELSIUS + ), 1) + response['thermostatTemperatureSetpointHigh'] = \ + target_temp + response['thermostatTemperatureSetpointLow'] = \ + target_temp else: target_temp = attrs.get(ATTR_TEMPERATURE) if target_temp is not None: - target_temp = round( + response['thermostatTemperatureSetpoint'] = round( temp_util.convert(target_temp, unit, TEMP_CELSIUS), 1) - response['thermostatTemperatureSetpointHigh'] = target_temp - response['thermostatTemperatureSetpointLow'] = target_temp - else: - target_temp = attrs.get(ATTR_TEMPERATURE) - if target_temp is not None: - response['thermostatTemperatureSetpoint'] = round( - temp_util.convert(target_temp, unit, TEMP_CELSIUS), 1) return response async def execute(self, command, data, params, challenge): """Execute a temperature point or mode command.""" + domain = self.state.domain + if domain == sensor.DOMAIN: + raise SmartHomeError( + ERR_NOT_SUPPORTED, + 'Execute is not supported by sensor') + # All sent in temperatures are always in Celsius unit = self.hass.config.units.temperature_unit min_temp = self.state.attributes[climate.ATTR_MIN_TEMP] @@ -682,8 +725,8 @@ class TemperatureSettingTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, } - if(supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH and - supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW): + if(supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH + and supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW): svc_data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high svc_data[climate.ATTR_TARGET_TEMP_LOW] = temp_low else: @@ -732,6 +775,11 @@ class LockUnlockTrait(_Trait): """Test if state is supported.""" return domain == lock.DOMAIN + @staticmethod + def might_2fa(domain, features, device_class): + """Return if the trait might ask for 2FA.""" + return True + def sync_attributes(self): """Return LockUnlock attributes for a sync request.""" return {} @@ -745,7 +793,7 @@ class LockUnlockTrait(_Trait): if params['lock']: service = lock.SERVICE_LOCK else: - _verify_pin_challenge(data, challenge) + _verify_pin_challenge(data, self.state, challenge) service = lock.SERVICE_UNLOCK await self.hass.services.async_call(lock.DOMAIN, service, { @@ -1021,6 +1069,9 @@ class OpenCloseTrait(_Trait): https://developers.google.com/actions/smarthome/traits/openclose """ + # Cover device classes that require 2FA + COVER_2FA = (cover.DEVICE_CLASS_DOOR, cover.DEVICE_CLASS_GARAGE) + name = TRAIT_OPENCLOSE commands = [ COMMAND_OPENCLOSE @@ -1042,6 +1093,12 @@ class OpenCloseTrait(_Trait): binary_sensor.DEVICE_CLASS_WINDOW, ) + @staticmethod + def might_2fa(domain, features, device_class): + """Return if the trait might ask for 2FA.""" + return (domain == cover.DOMAIN and + device_class in OpenCloseTrait.COVER_2FA) + def sync_attributes(self): """Return opening direction.""" response = {} @@ -1114,9 +1171,8 @@ class OpenCloseTrait(_Trait): if (should_verify and self.state.attributes.get(ATTR_DEVICE_CLASS) - in (cover.DEVICE_CLASS_DOOR, - cover.DEVICE_CLASS_GARAGE)): - _verify_pin_challenge(data, challenge) + in OpenCloseTrait.COVER_2FA): + _verify_pin_challenge(data, self.state, challenge) await self.hass.services.async_call( cover.DOMAIN, service, svc_params, @@ -1202,8 +1258,11 @@ class VolumeTrait(_Trait): ERR_NOT_SUPPORTED, 'Command not supported') -def _verify_pin_challenge(data, challenge): +def _verify_pin_challenge(data, state, challenge): """Verify a pin challenge.""" + if not data.config.should_2fa(state): + return + if not data.config.secure_devices_pin: raise SmartHomeError( ERR_CHALLENGE_NOT_SETUP, 'Challenge is not set up') @@ -1217,7 +1276,7 @@ def _verify_pin_challenge(data, challenge): raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED) -def _verify_ack_challenge(data, challenge): +def _verify_ack_challenge(data, state, challenge): """Verify a pin challenge.""" if not challenge or not challenge.get('ack'): raise ChallengeNeeded(CHALLENGE_ACK_NEEDED) diff --git a/homeassistant/components/google_domains/__init__.py b/homeassistant/components/google_domains/__init__.py index f884e46cc4c..2d3736d2ec3 100644 --- a/homeassistant/components/google_domains/__init__.py +++ b/homeassistant/components/google_domains/__init__.py @@ -67,7 +67,7 @@ async def _update_google_domains( } try: - with async_timeout.timeout(timeout, loop=hass.loop): + with async_timeout.timeout(timeout): resp = await session.get(url, params=params) body = await resp.text() diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py index 4d988bed21c..0f067cf13b9 100644 --- a/homeassistant/components/google_translate/tts.py +++ b/homeassistant/components/google_translate/tts.py @@ -87,7 +87,7 @@ class GoogleProvider(Provider): } try: - with async_timeout.timeout(10, loop=self.hass.loop): + with async_timeout.timeout(10): request = await websession.get( GOOGLE_SPEECH_URL, params=url_param, headers=self.headers diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py index 6887b85d02d..2123421334a 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -11,10 +11,10 @@ from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, \ from homeassistant.helpers import config_entry_flow from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -DOMAIN = 'gpslogger' TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN) ATTR_ALTITUDE = 'altitude' @@ -50,6 +50,10 @@ WEBHOOK_SCHEMA = vol.Schema({ async def async_setup(hass, hass_config): """Set up the GPSLogger component.""" + hass.data[DOMAIN] = { + 'devices': set(), + 'unsub_device_tracker': {}, + } return True @@ -98,19 +102,10 @@ async def async_setup_entry(hass, entry): async def async_unload_entry(hass, entry): """Unload a config entry.""" hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) - + hass.data[DOMAIN]['unsub_device_tracker'].pop(entry.entry_id)() await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER) return True # pylint: disable=invalid-name async_remove_entry = config_entry_flow.webhook_async_remove_entry - - -config_entry_flow.register_webhook_flow( - DOMAIN, - 'GPSLogger Webhook', - { - 'docs_url': 'https://www.home-assistant.io/components/gpslogger/' - } -) diff --git a/homeassistant/components/gpslogger/config_flow.py b/homeassistant/components/gpslogger/config_flow.py new file mode 100644 index 00000000000..f48d9abc680 --- /dev/null +++ b/homeassistant/components/gpslogger/config_flow.py @@ -0,0 +1,12 @@ +"""Config flow for GPSLogger.""" +from homeassistant.helpers import config_entry_flow +from .const import DOMAIN + + +config_entry_flow.register_webhook_flow( + DOMAIN, + 'GPSLogger Webhook', + { + 'docs_url': 'https://www.home-assistant.io/components/gpslogger/' + } +) diff --git a/homeassistant/components/gpslogger/const.py b/homeassistant/components/gpslogger/const.py new file mode 100644 index 00000000000..e37c7f0d77b --- /dev/null +++ b/homeassistant/components/gpslogger/const.py @@ -0,0 +1,3 @@ +"""Const for GPSLogger.""" + +DOMAIN = 'gpslogger' diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index 67967821083..49d421cbc8c 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -1,37 +1,123 @@ """Support for the GPSLogger device tracking.""" import logging -from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN) +from homeassistant.core import callback +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import ( + DeviceTrackerEntity +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as GPSLOGGER_DOMAIN, TRACKER_UPDATE +from . import DOMAIN as GPL_DOMAIN, TRACKER_UPDATE _LOGGER = logging.getLogger(__name__) -DATA_KEY = '{}.{}'.format(GPSLOGGER_DOMAIN, DEVICE_TRACKER_DOMAIN) - -async def async_setup_entry(hass: HomeAssistantType, entry, async_see): +async def async_setup_entry(hass: HomeAssistantType, entry, + async_add_entities): """Configure a dispatcher connection based on a config entry.""" - async def _set_location(device, gps_location, battery, accuracy, attrs): - """Fire HA event to set location.""" - await async_see( - dev_id=device, - gps=gps_location, - battery=battery, - gps_accuracy=accuracy, - attributes=attrs - ) + @callback + def _receive_data(device, gps, battery, accuracy, attrs): + """Receive set location.""" + if device in hass.data[GPL_DOMAIN]['devices']: + return - hass.data[DATA_KEY] = async_dispatcher_connect( - hass, TRACKER_UPDATE, _set_location - ) - return True + hass.data[GPL_DOMAIN]['devices'].add(device) + + async_add_entities([GPSLoggerEntity( + device, gps, battery, accuracy, attrs + )]) + + hass.data[GPL_DOMAIN]['unsub_device_tracker'][entry.entry_id] = \ + async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) -async def async_unload_entry(hass: HomeAssistantType, entry): - """Unload the config entry and remove the dispatcher connection.""" - hass.data[DATA_KEY]() - return True +class GPSLoggerEntity(DeviceTrackerEntity): + """Represent a tracked device.""" + + def __init__( + self, device, location, battery, accuracy, attributes): + """Set up Geofency entity.""" + self._accuracy = accuracy + self._attributes = attributes + self._name = device + self._battery = battery + self._location = location + self._unsub_dispatcher = None + self._unique_id = device + + @property + def battery_level(self): + """Return battery value of the device.""" + return self._battery + + @property + def device_state_attributes(self): + """Return device specific attributes.""" + return self._attributes + + @property + def latitude(self): + """Return latitude value of the device.""" + return self._location[0] + + @property + def longitude(self): + """Return longitude value of the device.""" + return self._location[1] + + @property + def location_accuracy(self): + """Return the gps accuracy of the device.""" + return self._accuracy + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def unique_id(self): + """Return the unique ID.""" + return self._unique_id + + @property + def device_info(self): + """Return the device info.""" + return { + 'name': self._name, + 'identifiers': {(GPL_DOMAIN, self._unique_id)}, + } + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + async def async_added_to_hass(self): + """Register state update callback.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, TRACKER_UPDATE, self._async_receive_data) + + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + self._unsub_dispatcher() + + @callback + def _async_receive_data(self, device, location, battery, accuracy, + attributes): + """Mark the device as seen.""" + if device != self.name: + return + + self._location = location + self._battery = battery + self._accuracy = accuracy + self._attributes.update(attributes) + self.async_write_ha_state() diff --git a/homeassistant/components/gpslogger/manifest.json b/homeassistant/components/gpslogger/manifest.json index 2d2166c1bb1..f039e50914b 100644 --- a/homeassistant/components/gpslogger/manifest.json +++ b/homeassistant/components/gpslogger/manifest.json @@ -1,6 +1,7 @@ { "domain": "gpslogger", "name": "Gpslogger", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/gpslogger", "requirements": [], "dependencies": [ diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 80ac01a78ac..d13580ec42a 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -306,7 +306,7 @@ async def async_setup(hass, config): tasks.append(group.async_update_ha_state()) if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) hass.services.async_register( DOMAIN, SERVICE_SET_VISIBILITY, visibility_service_handler, diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index b59c49563e2..e13499878e9 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -65,4 +65,4 @@ class GroupNotifyPlatform(BaseNotificationService): DOMAIN, entity.get(ATTR_SERVICE), sending_payload)) if tasks: - await asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks) diff --git a/homeassistant/components/hangouts/.translations/fr.json b/homeassistant/components/hangouts/.translations/fr.json index 00a7d5fd80d..0b6dbfcbe44 100644 --- a/homeassistant/components/hangouts/.translations/fr.json +++ b/homeassistant/components/hangouts/.translations/fr.json @@ -18,6 +18,7 @@ }, "user": { "data": { + "authorization_code": "Code d'autorisation (requis pour l'authentification manuelle)", "email": "Adresse e-mail", "password": "Mot de passe" }, diff --git a/homeassistant/components/hangouts/manifest.json b/homeassistant/components/hangouts/manifest.json index 5d9bf3c7612..4a90e9c977e 100644 --- a/homeassistant/components/hangouts/manifest.json +++ b/homeassistant/components/hangouts/manifest.json @@ -1,6 +1,7 @@ { "domain": "hangouts", "name": "Hangouts", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/hangouts", "requirements": [ "hangups==0.4.9" diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index 7291a87e954..e85c8f12247 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -61,7 +61,7 @@ class HassIOAddonPanel(HomeAssistantView): async def delete(self, request, addon): """Handle remove add-on panel requests.""" - # Currently not supported by backend / frontend + self.hass.components.frontend.async_remove_panel(addon) return web.Response() async def get_panels(self): diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index aae1f31d486..1e6e1c2fffe 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -156,7 +156,7 @@ class HassIO: This method is a coroutine. """ try: - with async_timeout.timeout(timeout, loop=self.loop): + with async_timeout.timeout(timeout): request = await self.websession.request( method, "http://{}{}".format(self._ip, command), json=payload, headers={ diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index a798d312c25..a9c5deda9f9 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -71,13 +71,11 @@ class HassIOView(HomeAssistantView): This method is a coroutine. """ read_timeout = _get_timeout(path) - hass = request.app['hass'] - data = None headers = _init_header(request) try: - with async_timeout.timeout(10, loop=hass.loop): + with async_timeout.timeout(10): data = await request.read() method = getattr(self._websession, request.method.lower()) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 824dee86fad..250d50681dc 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -119,8 +119,12 @@ class HassIOIngress(HomeAssistantView): source_header = _init_header(request, token) async with self._websession.request( - request.method, url, headers=source_header, - params=request.query, data=data + request.method, + url, + headers=source_header, + params=request.query, + allow_redirects=False, + data=data ) as result: headers = _response_header(result) diff --git a/homeassistant/components/heos/.translations/fr.json b/homeassistant/components/heos/.translations/fr.json index 274075af749..549cd00e8e0 100644 --- a/homeassistant/components/heos/.translations/fr.json +++ b/homeassistant/components/heos/.translations/fr.json @@ -9,7 +9,8 @@ "step": { "user": { "data": { - "access_token": "H\u00f4te" + "access_token": "H\u00f4te", + "host": "H\u00f4te" }, "description": "Veuillez saisir le nom d\u2019h\u00f4te ou l\u2019adresse IP d\u2019un p\u00e9riph\u00e9rique Heos (de pr\u00e9f\u00e9rence connect\u00e9 au r\u00e9seau filaire).", "title": "Se connecter \u00e0 Heos" diff --git a/homeassistant/components/heos/.translations/nl.json b/homeassistant/components/heos/.translations/nl.json new file mode 100644 index 00000000000..d3c91af2c16 --- /dev/null +++ b/homeassistant/components/heos/.translations/nl.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_token": "Host" + }, + "title": "Verbinding maken met Heos" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/no.json b/homeassistant/components/heos/.translations/no.json index 144b08c0663..dd4cb48a090 100644 --- a/homeassistant/components/heos/.translations/no.json +++ b/homeassistant/components/heos/.translations/no.json @@ -16,6 +16,6 @@ "title": "Koble til Heos" } }, - "title": "Heos" + "title": "HEOS" } } \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/sv.json b/homeassistant/components/heos/.translations/sv.json index d36ad203438..96d4991a5b8 100644 --- a/homeassistant/components/heos/.translations/sv.json +++ b/homeassistant/components/heos/.translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_setup": "Du kan bara konfigurera en enda Heos-anslutning eftersom den kommer att st\u00f6dja alla enheter i n\u00e4tverket." + }, "error": { "connection_failure": "Det gick inte att ansluta till den angivna v\u00e4rden." }, @@ -13,6 +16,6 @@ "title": "Anslut till Heos" } }, - "title": "Heos" + "title": "HEOS" } } \ No newline at end of file diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 6585393d12e..7a6cb36ab7b 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -4,6 +4,7 @@ from datetime import timedelta import logging from typing import Dict +from pyheos import CommandError, Heos, const as heos_const import voluptuous as vol from homeassistant.components.media_player.const import ( @@ -57,7 +58,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Initialize config entry which represents the HEOS controller.""" - from pyheos import Heos, CommandError host = entry.data[CONF_HOST] # Setting all_progress_events=False ensures that we only receive a # media position update upon start of playback or when media changes @@ -137,16 +137,15 @@ class ControllerManager: async def connect_listeners(self): """Subscribe to events of interest.""" - from pyheos import const self._device_registry, self._entity_registry = await asyncio.gather( self._hass.helpers.device_registry.async_get_registry(), self._hass.helpers.entity_registry.async_get_registry()) # Handle controller events self._signals.append(self.controller.dispatcher.connect( - const.SIGNAL_CONTROLLER_EVENT, self._controller_event)) + heos_const.SIGNAL_CONTROLLER_EVENT, self._controller_event)) # Handle connection-related events self._signals.append(self.controller.dispatcher.connect( - const.SIGNAL_HEOS_EVENT, self._heos_event)) + heos_const.SIGNAL_HEOS_EVENT, self._heos_event)) async def disconnect(self): """Disconnect subscriptions.""" @@ -158,21 +157,19 @@ class ControllerManager: async def _controller_event(self, event, data): """Handle controller event.""" - from pyheos import const - if event == const.EVENT_PLAYERS_CHANGED: - self.update_ids(data[const.DATA_MAPPED_IDS]) + if event == heos_const.EVENT_PLAYERS_CHANGED: + self.update_ids(data[heos_const.DATA_MAPPED_IDS]) # Update players self._hass.helpers.dispatcher.async_dispatcher_send( SIGNAL_HEOS_UPDATED) async def _heos_event(self, event): """Handle connection event.""" - from pyheos import CommandError, const - if event == const.EVENT_CONNECTED: + if event == heos_const.EVENT_CONNECTED: try: # Retrieve latest players and refresh status data = await self.controller.load_players() - self.update_ids(data[const.DATA_MAPPED_IDS]) + self.update_ids(data[heos_const.DATA_MAPPED_IDS]) except (CommandError, asyncio.TimeoutError, ConnectionError) as ex: _LOGGER.error("Unable to refresh players: %s", ex) # Update players @@ -241,9 +238,8 @@ class SourceManager: def get_current_source(self, now_playing_media): """Determine current source from now playing media.""" - from pyheos import const # Match input by input_name:media_id - if now_playing_media.source_id == const.MUSIC_SOURCE_AUX_INPUT: + if now_playing_media.source_id == heos_const.MUSIC_SOURCE_AUX_INPUT: return next((input_source.name for input_source in self.inputs if input_source.input_name == now_playing_media.media_id), None) @@ -260,8 +256,6 @@ class SourceManager: physical event therefore throttle it. Retrieving sources immediately after the event may fail so retry. """ - from pyheos import CommandError, const - @Throttle(MIN_UPDATE_SOURCES) async def get_sources(): retry_attempts = 0 @@ -286,9 +280,9 @@ class SourceManager: return async def update_sources(event, data=None): - if event in (const.EVENT_SOURCES_CHANGED, - const.EVENT_USER_CHANGED, - const.EVENT_CONNECTED): + if event in (heos_const.EVENT_SOURCES_CHANGED, + heos_const.EVENT_USER_CHANGED, + heos_const.EVENT_CONNECTED): sources = await get_sources() # If throttled, it will return None if sources: @@ -300,6 +294,6 @@ class SourceManager: SIGNAL_HEOS_UPDATED) controller.dispatcher.connect( - const.SIGNAL_CONTROLLER_EVENT, update_sources) + heos_const.SIGNAL_CONTROLLER_EVENT, update_sources) controller.dispatcher.connect( - const.SIGNAL_HEOS_EVENT, update_sources) + heos_const.SIGNAL_HEOS_EVENT, update_sources) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index 656058877db..064813a86a7 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure Heos.""" import asyncio +from pyheos import Heos import voluptuous as vol from homeassistant import config_entries @@ -44,7 +45,6 @@ class HeosFlowHandler(config_entries.ConfigFlow): async def async_step_user(self, user_input=None): """Obtain host and validate connection.""" - from pyheos import Heos self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {}) # Only a single entry is needed for all devices if self._async_current_entries(): diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index f3a2ff4eccf..a1fc8030318 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -1,6 +1,7 @@ { "domain": "heos", "name": "HEOS", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/heos", "requirements": [ "pyheos==0.5.2" diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 00a3b721efb..ff5c2d707f2 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -5,6 +5,8 @@ import logging from operator import ior from typing import Sequence +from pyheos import CommandError, const as heos_const + from homeassistant.components.media_player import MediaPlayerDevice from homeassistant.components.media_player.const import ( ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, @@ -25,6 +27,20 @@ BASE_SUPPORTED_FEATURES = SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \ SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOURCE | \ SUPPORT_PLAY_MEDIA +PLAY_STATE_TO_STATE = { + heos_const.PLAY_STATE_PLAY: STATE_PLAYING, + heos_const.PLAY_STATE_STOP: STATE_IDLE, + heos_const.PLAY_STATE_PAUSE: STATE_PAUSED +} + +CONTROL_TO_SUPPORT = { + heos_const.CONTROL_PLAY: SUPPORT_PLAY, + heos_const.CONTROL_PAUSE: SUPPORT_PAUSE, + heos_const.CONTROL_STOP: SUPPORT_STOP, + heos_const.CONTROL_PLAY_PREVIOUS: SUPPORT_PREVIOUS_TRACK, + heos_const.CONTROL_PLAY_NEXT: SUPPORT_NEXT_TRACK +} + _LOGGER = logging.getLogger(__name__) @@ -47,7 +63,6 @@ def log_command_error(command: str): def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): - from pyheos import CommandError try: await func(*args, **kwargs) except (CommandError, asyncio.TimeoutError, ConnectionError, @@ -62,31 +77,17 @@ class HeosMediaPlayer(MediaPlayerDevice): def __init__(self, player): """Initialize.""" - from pyheos import const self._media_position_updated_at = None self._player = player self._signals = [] self._supported_features = BASE_SUPPORTED_FEATURES self._source_manager = None - self._play_state_to_state = { - const.PLAY_STATE_PLAY: STATE_PLAYING, - const.PLAY_STATE_STOP: STATE_IDLE, - const.PLAY_STATE_PAUSE: STATE_PAUSED - } - self._control_to_support = { - const.CONTROL_PLAY: SUPPORT_PLAY, - const.CONTROL_PAUSE: SUPPORT_PAUSE, - const.CONTROL_STOP: SUPPORT_STOP, - const.CONTROL_PLAY_PREVIOUS: SUPPORT_PREVIOUS_TRACK, - const.CONTROL_PLAY_NEXT: SUPPORT_NEXT_TRACK - } async def _player_update(self, player_id, event): """Handle player attribute updated.""" - from pyheos import const if self._player.player_id != player_id: return - if event == const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: + if event == heos_const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: self._media_position_updated_at = utcnow() await self.async_update_ha_state(True) @@ -96,11 +97,10 @@ class HeosMediaPlayer(MediaPlayerDevice): async def async_added_to_hass(self): """Device added to hass.""" - from pyheos import const self._source_manager = self.hass.data[HEOS_DOMAIN][DATA_SOURCE_MANAGER] # Update state when attributes of the player change self._signals.append(self._player.heos.dispatcher.connect( - const.SIGNAL_PLAYER_EVENT, self._player_update)) + heos_const.SIGNAL_PLAYER_EVENT, self._player_update)) # Update state when heos changes self._signals.append( self.hass.helpers.dispatcher.async_dispatcher_connect( @@ -163,14 +163,13 @@ class HeosMediaPlayer(MediaPlayerDevice): return if media_type == MEDIA_TYPE_PLAYLIST: - from pyheos import const playlists = await self._player.heos.get_playlists() playlist = next((p for p in playlists if p.name == media_id), None) if not playlist: raise ValueError("Invalid playlist '{}'".format(media_id)) - add_queue_option = const.ADD_QUEUE_ADD_TO_END \ + add_queue_option = heos_const.ADD_QUEUE_ADD_TO_END \ if kwargs.get(ATTR_MEDIA_ENQUEUE) \ - else const.ADD_QUEUE_REPLACE_AND_PLAY + else heos_const.ADD_QUEUE_REPLACE_AND_PLAY await self._player.add_to_queue(playlist, add_queue_option) return @@ -208,7 +207,7 @@ class HeosMediaPlayer(MediaPlayerDevice): async def async_update(self): """Update supported features of the player.""" controls = self._player.now_playing_media.supported_controls - current_support = [self._control_to_support[control] + current_support = [CONTROL_TO_SUPPORT[control] for control in controls] self._supported_features = reduce(ior, current_support, BASE_SUPPORTED_FEATURES) @@ -343,7 +342,7 @@ class HeosMediaPlayer(MediaPlayerDevice): @property def state(self) -> str: """State of the player.""" - return self._play_state_to_state[self._player.state] + return PLAY_STATE_TO_STATE[self._player.state] @property def supported_features(self) -> int: diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 7efe4f2beb2..d0dd098638f 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -252,7 +252,7 @@ async def async_setup(hass, config): use_include_order = conf.get(CONF_ORDER) hass.http.register_view(HistoryPeriodView(filters, use_include_order)) - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'history', 'history', 'hass:poll-box') return True diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index ef01d133cff..93a197969ca 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -64,7 +64,7 @@ async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]: tasks.append(hass.services.async_call( domain, service.service, data, blocking)) - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) hass.services.async_register( ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service) diff --git a/homeassistant/components/homekit_controller/.translations/en.json b/homeassistant/components/homekit_controller/.translations/en.json index 059f0f7afe7..31731a52203 100644 --- a/homeassistant/components/homekit_controller/.translations/en.json +++ b/homeassistant/components/homekit_controller/.translations/en.json @@ -3,6 +3,7 @@ "abort": { "accessory_not_found_error": "Cannot add pairing as device can no longer be found.", "already_configured": "Accessory is already configured with this controller.", + "already_in_progress": "Config flow for device is already in progress.", "already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.", "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.", "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed.", diff --git a/homeassistant/components/homekit_controller/.translations/es.json b/homeassistant/components/homekit_controller/.translations/es.json index f22b4158698..642e76fd1dd 100644 --- a/homeassistant/components/homekit_controller/.translations/es.json +++ b/homeassistant/components/homekit_controller/.translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "accessory_not_found_error": "No se puede a\u00f1adir el emparejamiento porque ya no se puede encontrar el dispositivo.", "already_configured": "El accesorio ya est\u00e1 configurado con este controlador.", "already_paired": "Este accesorio ya est\u00e1 emparejado con otro dispositivo. Por favor, reinicia el accesorio e int\u00e9ntalo de nuevo.", "ignored_model": "El soporte de HomeKit para este modelo est\u00e1 bloqueado ya que est\u00e1 disponible una integraci\u00f3n nativa m\u00e1s completa.", @@ -9,9 +10,14 @@ }, "error": { "authentication_error": "C\u00f3digo HomeKit incorrecto. Por favor, compru\u00e9belo e int\u00e9ntelo de nuevo.", + "busy_error": "El dispositivo rechaz\u00f3 el emparejamiento porque ya est\u00e1 emparejado con otro controlador.", + "max_peers_error": "El dispositivo rechaz\u00f3 el emparejamiento ya que no tiene almacenamiento de emparejamientos libres.", + "max_tries_error": "El dispositivo rechaz\u00f3 el emparejamiento ya que ha recibido m\u00e1s de 100 intentos de autenticaci\u00f3n fallidos.", + "pairing_failed": "Se ha producido un error no controlado al intentar emparejarse con este dispositivo. Esto puede ser un fallo temporal o que tu dispositivo no est\u00e9 admitido en este momento.", "unable_to_pair": "No se ha podido emparejar, por favor int\u00e9ntelo de nuevo.", "unknown_error": "El dispositivo report\u00f3 un error desconocido. La vinculaci\u00f3n ha fallado." }, + "flow_title": "Accesorio HomeKit: {name}", "step": { "pair": { "data": { diff --git a/homeassistant/components/homekit_controller/.translations/fr.json b/homeassistant/components/homekit_controller/.translations/fr.json index 73cbbdf046a..955e11d12b0 100644 --- a/homeassistant/components/homekit_controller/.translations/fr.json +++ b/homeassistant/components/homekit_controller/.translations/fr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "accessory_not_found_error": "Impossible d'ajouter le couplage car l'appareil est introuvable.", "already_configured": "L'accessoire est d\u00e9j\u00e0 configur\u00e9 avec ce contr\u00f4leur.", "already_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.", @@ -9,9 +10,14 @@ }, "error": { "authentication_error": "Code HomeKit incorrect. S'il vous pla\u00eet v\u00e9rifier et essayez \u00e0 nouveau.", + "busy_error": "L'appareil a refus\u00e9 d'ajouter le couplage car il est d\u00e9j\u00e0 coupl\u00e9 avec un autre contr\u00f4leur.", + "max_peers_error": "L'appareil a refus\u00e9 d'ajouter le couplage car il ne dispose pas de stockage de couplage libre.", + "max_tries_error": "Le p\u00e9riph\u00e9rique a refus\u00e9 d'ajouter le couplage car il a re\u00e7u plus de 100 tentatives d'authentification infructueuses.", + "pairing_failed": "Une erreur non g\u00e9r\u00e9e s'est produite lors de la tentative d'appairage avec cet appareil. Il se peut qu'il s'agisse d'une panne temporaire ou que votre appareil ne soit pas pris en charge actuellement.", "unable_to_pair": "Impossible d'appairer, veuillez r\u00e9essayer.", "unknown_error": "L'appareil a signal\u00e9 une erreur inconnue. L'appairage a \u00e9chou\u00e9." }, + "flow_title": "Accessoire HomeKit: {name}", "step": { "pair": { "data": { diff --git a/homeassistant/components/homekit_controller/.translations/ko.json b/homeassistant/components/homekit_controller/.translations/ko.json index c780f07e96e..5ee62ad62b4 100644 --- a/homeassistant/components/homekit_controller/.translations/ko.json +++ b/homeassistant/components/homekit_controller/.translations/ko.json @@ -13,7 +13,7 @@ "busy_error": "\uae30\uae30\uac00 \uc774\ubbf8 \ub2e4\ub978 \ucee8\ud2b8\ub864\ub7ec\uc640 \ud398\uc5b4\ub9c1 \uc911\uc774\ubbc0\ub85c \ud398\uc5b4\ub9c1 \ucd94\uac00\ub97c \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", "max_peers_error": "\uae30\uae30\uc5d0 \ube44\uc5b4\uc788\ub294 \ud398\uc5b4\ub9c1 \uc7a5\uc18c\uac00 \uc5c6\uc5b4 \ud398\uc5b4\ub9c1 \ucd94\uac00\ub97c \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", "max_tries_error": "\uae30\uae30\uac00 \uc2e4\ud328\ud55c \uc778\uc99d \uc2dc\ub3c4 \ud69f\uc218\uac00 100 \ud68c\ub97c \ucd08\uacfc\ud558\uc5ec \ud398\uc5b4\ub9c1\uc744 \ucd94\uac00\ub97c \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", - "pairing_failed": "\uc774 \uae30\uae30\uc640 \ud398\uc5b4\ub9c1\uc744 \uc2dc\ub3c4\ud558\ub294 \uc911 \ucc98\ub9ac\ub418\uc9c0 \uc54a\uc740 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uc77c\uc2dc\uc801\uc778 \uc624\ub958\uc774\uac70\ub098 \ud604\uc7ac \uc9c0\uc6d0\ub418\uc9c0 \uc54a\ub294 \uc7a5\uce58 \uc77c \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "pairing_failed": "\uc774 \uae30\uae30\uc640 \ud398\uc5b4\ub9c1\uc744 \uc2dc\ub3c4\ud558\ub294 \uc911 \ucc98\ub9ac\ub418\uc9c0 \uc54a\uc740 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uc77c\uc2dc\uc801\uc778 \uc624\ub958\uc774\uac70\ub098 \ud604\uc7ac \uc9c0\uc6d0\ub418\uc9c0 \uc54a\ub294 \uae30\uae30 \uc77c \uc218 \uc788\uc2b5\ub2c8\ub2e4.", "unable_to_pair": "\ud398\uc5b4\ub9c1 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", "unknown_error": "\uae30\uae30\uc5d0\uc11c \uc54c \uc218\uc5c6\ub294 \uc624\ub958\ub97c \ubcf4\uace0\ud588\uc2b5\ub2c8\ub2e4. \ud398\uc5b4\ub9c1\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4." }, diff --git a/homeassistant/components/homekit_controller/.translations/nl.json b/homeassistant/components/homekit_controller/.translations/nl.json index 30380344d9b..a714934372b 100644 --- a/homeassistant/components/homekit_controller/.translations/nl.json +++ b/homeassistant/components/homekit_controller/.translations/nl.json @@ -6,6 +6,7 @@ }, "error": { "authentication_error": "Onjuiste HomeKit-code. Controleer het en probeer het opnieuw.", + "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." }, diff --git a/homeassistant/components/homekit_controller/.translations/no.json b/homeassistant/components/homekit_controller/.translations/no.json index 555faef1061..e7ec6c279fa 100644 --- a/homeassistant/components/homekit_controller/.translations/no.json +++ b/homeassistant/components/homekit_controller/.translations/no.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "accessory_not_found_error": "Kan ikke legge til sammenkobling da enheten ikke lenger kan bli funnet.", "already_configured": "Tilbeh\u00f8r er allerede konfigurert med denne kontrolleren.", "already_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.", @@ -9,6 +10,10 @@ }, "error": { "authentication_error": "Ugyldig HomeKit kode. Vennligst sjekk den og pr\u00f8v igjen.", + "busy_error": "Enheten nekter \u00e5 sammenkoble da den allerede er sammenkoblet med en annen kontroller.", + "max_peers_error": "Enheten nekter \u00e5 sammenkoble da den ikke har ledig sammenkoblingslagring.", + "max_tries_error": "Enheten nekter \u00e5 sammenkoble da den har mottatt mer enn 100 mislykkede godkjenningsfors\u00f8k.", + "pairing_failed": "En uh\u00e5ndtert feil oppstod under fors\u00f8k p\u00e5 \u00e5 koble til denne enheten. Dette kan v\u00e6re en midlertidig feil, eller at enheten din kan ikke st\u00f8ttes for \u00f8yeblikket.", "unable_to_pair": "Kunne ikke koble til, vennligst pr\u00f8v igjen.", "unknown_error": "Enheten rapporterte en ukjent feil. Sammenkobling mislyktes." }, diff --git a/homeassistant/components/homekit_controller/.translations/pl.json b/homeassistant/components/homekit_controller/.translations/pl.json index acbc6ee81f7..a0489aa083a 100644 --- a/homeassistant/components/homekit_controller/.translations/pl.json +++ b/homeassistant/components/homekit_controller/.translations/pl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "accessory_not_found_error": "Nie mo\u017cna rozpocz\u0105\u0107 parowania, poniewa\u017c nie znaleziono urz\u0105dzenia.", "already_configured": "Akcesorium jest ju\u017c skonfigurowane z tym kontrolerem.", "already_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.", @@ -9,6 +10,10 @@ }, "error": { "authentication_error": "Niepoprawny kod parowania HomeKit. Sprawd\u017a go i spr\u00f3buj ponownie.", + "busy_error": "Urz\u0105dzenie odm\u00f3wi\u0142o parowania, poniewa\u017c jest ju\u017c powi\u0105zane z innym kontrolerem.", + "max_peers_error": "Urz\u0105dzenie odm\u00f3wi\u0142o parowania, poniewa\u017c nie ma wolnej pami\u0119ci parowania.", + "max_tries_error": "Urz\u0105dzenie odm\u00f3wi\u0142o parowania, poniewa\u017c otrzyma\u0142o ponad 100 nieudanych pr\u00f3b uwierzytelnienia.", + "pairing_failed": "Wyst\u0105pi\u0142 nieobs\u0142ugiwany b\u0142\u0105d podczas pr\u00f3by sparowania z tym urz\u0105dzeniem. Mo\u017ce to by\u0107 tymczasowa awaria lub Twoje urz\u0105dzenie mo\u017ce nie by\u0107 obecnie obs\u0142ugiwane.", "unable_to_pair": "Nie mo\u017cna sparowa\u0107, spr\u00f3buj ponownie.", "unknown_error": "Urz\u0105dzenie zg\u0142osi\u0142o nieznany b\u0142\u0105d. Parowanie nie powiod\u0142o si\u0119." }, diff --git a/homeassistant/components/homekit_controller/.translations/sl.json b/homeassistant/components/homekit_controller/.translations/sl.json index afee189216d..0404dd7beb5 100644 --- a/homeassistant/components/homekit_controller/.translations/sl.json +++ b/homeassistant/components/homekit_controller/.translations/sl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "accessory_not_found_error": "Seznanjanja ni mogo\u010de dodati, ker naprave ni ve\u010d mogo\u010de najti.", "already_configured": "Dodatna oprema je \u017ee konfigurirana s tem krmilnikom.", "already_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.", @@ -9,15 +10,20 @@ }, "error": { "authentication_error": "Nepravilna koda HomeKit. Preverite in poskusite znova.", + "busy_error": "Naprava je zavrnila seznanjanje, saj se \u017ee povezuje z drugim krmilnikom.", + "max_peers_error": "Naprava je zavrnila seznanjanje, saj nima prostega pomnilnika za seznanjanje.", + "max_tries_error": "Napravaje zavrnila seznanjanje, saj je prejela ve\u010d kot 100 neuspe\u0161nih poskusov overjanja.", + "pairing_failed": "Pri poskusu seznanjanja s to napravo je pri\u0161lo do napake. To je lahko za\u010dasna napaka ali pa naprava trenutno ni podprta.", "unable_to_pair": "Ni mogo\u010de seznaniti. Poskusite znova.", "unknown_error": "Naprava je sporo\u010dila neznano napako. Seznanjanje ni uspelo." }, + "flow_title": "HomeKit Oprema: {name}", "step": { "pair": { "data": { "pairing_code": "Koda za seznanjanje" }, - "description": "Vnesi HomeKit kodo, \u010de \u017eeli\u0161 uporabiti to dodatno opremo", + "description": "\u010ce \u017eeli\u0161 uporabiti to dodatno opremo, vnesi HomeKit kodo.", "title": "Seznanite s HomeKit Opremo" }, "user": { diff --git a/homeassistant/components/homekit_controller/.translations/sv.json b/homeassistant/components/homekit_controller/.translations/sv.json index 32372840031..264fca2de50 100644 --- a/homeassistant/components/homekit_controller/.translations/sv.json +++ b/homeassistant/components/homekit_controller/.translations/sv.json @@ -1,11 +1,19 @@ { "config": { "abort": { + "accessory_not_found_error": "Kan inte genomf\u00f6ra parningsf\u00f6rs\u00f6ket eftersom enheten inte l\u00e4ngre kan hittas.", + "already_configured": "Tillbeh\u00f6ret \u00e4r redan konfigurerat med denna kontroller.", "already_paired": "Det h\u00e4r tillbeh\u00f6ret \u00e4r redan kopplat till en annan enhet. \u00c5terst\u00e4ll tillbeh\u00f6ret och f\u00f6rs\u00f6k igen.", + "ignored_model": "HomeKit-st\u00f6d f\u00f6r den h\u00e4r modellen blockeras eftersom en mer komplett inbyggd integration \u00e4r tillg\u00e4nglig.", + "invalid_config_entry": "Den h\u00e4r enheten visas som redo att paras ihop, men det finns redan en motstridig konfigurations-post f\u00f6r den i Home Assistant som f\u00f6rst m\u00e5ste tas bort.", "no_devices": "Inga oparade enheter kunde hittas" }, "error": { "authentication_error": "Felaktig HomeKit-kod. V\u00e4nligen kontrollera och f\u00f6rs\u00f6k igen.", + "busy_error": "Enheten nekade parning d\u00e5 den redan \u00e4r parad med annan controller.", + "max_peers_error": "Enheten nekade parningsf\u00f6rs\u00f6ket d\u00e5 det inte finns n\u00e5got parningsminnesutrymme kvar", + "max_tries_error": "Enheten nekade parningen d\u00e5 den har emottagit mer \u00e4n 100 misslyckade autentiseringsf\u00f6rs\u00f6k", + "pairing_failed": "Ett ok\u00e4nt fel uppstod n\u00e4r parningsf\u00f6rs\u00f6ket gjordes med den h\u00e4r enheten. Det h\u00e4r kan vara ett tillf\u00e4lligt fel, eller s\u00e5 st\u00f6ds inte din enhet i nul\u00e4get.", "unable_to_pair": "Det g\u00e5r inte att para ihop, f\u00f6rs\u00f6k igen.", "unknown_error": "Enheten rapporterade ett ok\u00e4nt fel. Parning misslyckades." }, @@ -15,7 +23,7 @@ "data": { "pairing_code": "Parningskod" }, - "description": "Ange din HomeKit-parningskod f\u00f6r att anv\u00e4nda det h\u00e4r tillbeh\u00f6ret", + "description": "Ange din HomeKit-parningskod (i formatet XXX-XX-XXX) f\u00f6r att anv\u00e4nda det h\u00e4r tillbeh\u00f6ret", "title": "Para HomeKit-tillbeh\u00f6r" }, "user": { diff --git a/homeassistant/components/homekit_controller/.translations/zh-Hans.json b/homeassistant/components/homekit_controller/.translations/zh-Hans.json index d8c7ba8c4da..aae5b68ceb2 100644 --- a/homeassistant/components/homekit_controller/.translations/zh-Hans.json +++ b/homeassistant/components/homekit_controller/.translations/zh-Hans.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "accessory_not_found_error": "\u65e0\u6cd5\u6dfb\u52a0\u914d\u5bf9\uff0c\u56e0\u4e3a\u65e0\u6cd5\u518d\u627e\u5230\u8bbe\u5907\u3002", "already_configured": "\u914d\u4ef6\u5df2\u901a\u8fc7\u6b64\u63a7\u5236\u5668\u914d\u7f6e\u5b8c\u6210\u3002", "already_paired": "\u6b64\u914d\u4ef6\u5df2\u4e0e\u53e6\u4e00\u53f0\u8bbe\u5907\u914d\u5bf9\u3002\u8bf7\u91cd\u7f6e\u914d\u4ef6\uff0c\u7136\u540e\u91cd\u8bd5\u3002", "ignored_model": "HomeKit \u5bf9\u6b64\u8bbe\u5907\u7684\u652f\u6301\u5df2\u88ab\u963b\u6b62\uff0c\u56e0\u4e3a\u6709\u529f\u80fd\u66f4\u5b8c\u6574\u7684\u539f\u751f\u96c6\u6210\u53ef\u4ee5\u4f7f\u7528\u3002", @@ -9,9 +10,14 @@ }, "error": { "authentication_error": "HomeKit \u4ee3\u7801\u4e0d\u6b63\u786e\u3002\u8bf7\u68c0\u67e5\u540e\u91cd\u8bd5\u3002", + "busy_error": "\u8bbe\u5907\u62d2\u7edd\u914d\u5bf9\uff0c\u56e0\u4e3a\u5b83\u5df2\u7ecf\u4e0e\u53e6\u4e00\u4e2a\u63a7\u5236\u5668\u914d\u5bf9\u3002", + "max_peers_error": "\u8bbe\u5907\u62d2\u7edd\u914d\u5bf9\uff0c\u56e0\u4e3a\u5b83\u6ca1\u6709\u7a7a\u95f2\u7684\u914d\u5bf9\u5b58\u50a8\u7a7a\u95f4\u3002", + "max_tries_error": "\u8bbe\u5907\u62d2\u7edd\u914d\u5bf9\uff0c\u56e0\u4e3a\u5b83\u5df2\u6536\u5230\u8d85\u8fc7 100 \u6b21\u5931\u8d25\u7684\u8eab\u4efd\u8ba4\u8bc1\u3002", + "pairing_failed": "\u5c1d\u8bd5\u4e0e\u6b64\u8bbe\u5907\u914d\u5bf9\u65f6\u53d1\u751f\u672a\u5904\u7406\u7684\u9519\u8bef\u3002\u8fd9\u53ef\u80fd\u662f\u6682\u65f6\u6027\u6545\u969c\uff0c\u4e5f\u53ef\u80fd\u662f\u60a8\u7684\u8bbe\u5907\u76ee\u524d\u4e0d\u88ab\u652f\u6301\u3002", "unable_to_pair": "\u65e0\u6cd5\u914d\u5bf9\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002", "unknown_error": "\u8bbe\u5907\u62a5\u544a\u4e86\u672a\u77e5\u9519\u8bef\u3002\u914d\u5bf9\u5931\u8d25\u3002" }, + "flow_title": "HomeKit \u914d\u4ef6", "step": { "pair": { "data": { diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 1b1c7b96b58..f1ddf1faacf 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -1,11 +1,12 @@ """Support for Homekit device discovery.""" import logging -from homeassistant.components.discovery import SERVICE_HOMEKIT -from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr -from .config_flow import load_old_pairings +# We need an import from .config_flow, without it .config_flow is never loaded. +from .config_flow import HomekitControllerFlowHandler # noqa: F401 from .connection import get_accessory_information, HKDevice from .const import ( CONTROLLER, ENTITY_MAP, KNOWN_DEVICES @@ -13,12 +14,6 @@ from .const import ( from .const import DOMAIN # noqa: pylint: disable=unused-import from .storage import EntityMapStorage -HOMEKIT_IGNORE = [ - 'BSB002', - 'Home Assistant Bridge', - 'TRADFRI gateway', -] - _LOGGER = logging.getLogger(__name__) @@ -100,7 +95,8 @@ class HomeKitEntity(Entity): """Obtain a HomeKit device's state.""" # pylint: disable=import-error from homekit.exceptions import ( - AccessoryDisconnectedError, AccessoryNotFoundError) + AccessoryDisconnectedError, AccessoryNotFoundError, + EncryptionError) try: new_values_dict = await self._accessory.get_characteristics( @@ -111,7 +107,7 @@ class HomeKitEntity(Entity): # visible on the network. self._available = False return - except AccessoryDisconnectedError: + except (AccessoryDisconnectedError, EncryptionError): # Temporary connection failure. Device is still available but our # connection was dropped. return @@ -145,66 +141,72 @@ class HomeKitEntity(Entity): """Return True if entity is available.""" return self._available + @property + def device_info(self): + """Return the device info.""" + accessory_serial = self._accessory_info['serial-number'] + + device_info = { + 'identifiers': { + (DOMAIN, 'serial-number', accessory_serial), + }, + 'name': self._accessory_info['name'], + 'manufacturer': self._accessory_info.get('manufacturer', ''), + 'model': self._accessory_info.get('model', ''), + 'sw_version': self._accessory_info.get('firmware.revision', ''), + } + + # Some devices only have a single accessory - we don't add a via_hub + # 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) + + return device_info + def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" raise NotImplementedError +async def async_setup_entry(hass, entry): + """Set up a HomeKit connection on a config entry.""" + conn = HKDevice(hass, entry, entry.data) + hass.data[KNOWN_DEVICES][conn.unique_id] = conn + + if not await conn.async_setup(): + del hass.data[KNOWN_DEVICES][conn.unique_id] + raise ConfigEntryNotReady + + conn_info = conn.connection_info + + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={ + (DOMAIN, 'serial-number', conn_info['serial-number']), + (DOMAIN, 'accessory-id', conn.unique_id), + }, + name=conn.name, + manufacturer=conn_info.get('manufacturer'), + model=conn_info.get('model'), + sw_version=conn_info.get('firmware.revision'), + ) + + return True + + async def async_setup(hass, config): """Set up for Homekit devices.""" # pylint: disable=import-error import homekit - from homekit.controller.ip_implementation import IpPairing map_storage = hass.data[ENTITY_MAP] = EntityMapStorage(hass) await map_storage.async_initialize() - hass.data[CONTROLLER] = controller = homekit.Controller() - - old_pairings = await hass.async_add_executor_job( - load_old_pairings, - hass - ) - for hkid, pairing_data in old_pairings.items(): - controller.pairings[hkid] = IpPairing(pairing_data) - - def discovery_dispatch(service, discovery_info): - """Dispatcher for Homekit discovery events.""" - # model, id - host = discovery_info['host'] - port = discovery_info['port'] - - # Fold property keys to lower case, making them effectively - # case-insensitive. Some HomeKit devices capitalize them. - properties = { - key.lower(): value - for (key, value) in discovery_info['properties'].items() - } - - model = properties['md'] - hkid = properties['id'] - config_num = int(properties['c#']) - - if model in HOMEKIT_IGNORE: - return - - # Only register a device once, but rescan if the config has changed - if hkid in hass.data[KNOWN_DEVICES]: - device = hass.data[KNOWN_DEVICES][hkid] - if config_num > device.config_num and \ - device.pairing is not None: - device.refresh_entity_map(config_num) - return - - _LOGGER.debug('Discovered unique device %s', hkid) - device = HKDevice(hass, host, port, model, hkid, config_num, config) - device.setup() - + hass.data[CONTROLLER] = homekit.Controller() hass.data[KNOWN_DEVICES] = {} - await hass.async_add_executor_job( - discovery.listen, hass, SERVICE_HOMEKIT, discovery_dispatch) - return True diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index fe15cfe2eab..93279bd626e 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -28,13 +28,25 @@ TARGET_STATE_MAP = { } -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit Alarm Control Panel support.""" - if discovery_info is None: - return - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitAlarmControlPanel(accessory, discovery_info)], - True) +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit alarm control panel.""" + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service['stype'] != 'security-system': + return False + info = {'aid': aid, 'iid': service['iid']} + async_add_entities([HomeKitAlarmControlPanel(conn, info)], True) + return True + + conn.add_listener(async_add_service) class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel): diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py index a5b70082002..b9922ea43bb 100644 --- a/homeassistant/components/homekit_controller/binary_sensor.py +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -8,11 +8,25 @@ from . import KNOWN_DEVICES, HomeKitEntity _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit motion sensor support.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitMotionSensor(accessory, discovery_info)], True) +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit lighting.""" + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service['stype'] != 'motion': + return False + info = {'aid': aid, 'iid': service['iid']} + async_add_entities([HomeKitMotionSensor(conn, info)], True) + return True + + conn.add_listener(async_add_service) class HomeKitMotionSensor(HomeKitEntity, BinarySensorDevice): diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 4c299d1c7d0..c5a6ee0c3dc 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -26,11 +26,25 @@ MODE_HASS_TO_HOMEKIT = {v: k for k, v in MODE_HOMEKIT_TO_HASS.items()} DEFAULT_VALID_MODES = list(MODE_HOMEKIT_TO_HASS) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Homekit climate.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitClimateDevice(accessory, discovery_info)], True) + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service['stype'] != 'thermostat': + return False + info = {'aid': aid, 'iid': service['iid']} + async_add_entities([HomeKitClimateDevice(conn, info)], True) + return True + + conn.add_listener(async_add_service) class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 197d15116b1..2ce8c0db6b7 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -66,35 +66,35 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): def __init__(self): """Initialize the homekit_controller flow.""" + import homekit # pylint: disable=import-error + self.model = None self.hkid = None self.devices = {} + self.controller = homekit.Controller() + self.finish_pairing = None async def async_step_user(self, user_input=None): """Handle a flow start.""" - import homekit - errors = {} if user_input is not None: key = user_input['device'] - props = self.devices[key]['properties'] - self.hkid = props['id'] - self.model = props['md'] + self.hkid = self.devices[key]['id'] + self.model = self.devices[key]['md'] return await self.async_step_pair() - controller = homekit.Controller() all_hosts = await self.hass.async_add_executor_job( - controller.discover, 5 + self.controller.discover, 5 ) self.devices = {} for host in all_hosts: - status_flags = int(host['properties']['sf']) + status_flags = int(host['sf']) paired = not status_flags & 0x01 if paired: continue - self.devices[host['properties']['id']] = host + self.devices[host['name']] = host if not self.devices: return self.async_abort( @@ -109,7 +109,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): }) ) - async def async_step_discovery(self, discovery_info): + async def async_step_zeroconf(self, discovery_info): """Handle a discovered HomeKit accessory. This flow is triggered by the discovery component. @@ -126,15 +126,24 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): # It changes if a device is factory reset. hkid = properties['id'] model = properties['md'] - + name = discovery_info['name'].replace('._hap._tcp.local.', '') status_flags = int(properties['sf']) paired = not status_flags & 0x01 + _LOGGER.debug("Discovered device %s (%s - %s)", name, model, hkid) + # pylint: disable=unsupported-assignment-operation + self.context['hkid'] = hkid self.context['title_placeholders'] = { - 'name': discovery_info['name'], + 'name': name, } + # If multiple HomekitControllerFlowHandler end up getting created + # for the same accessory dont let duplicates hang around + active_flows = self._async_in_progress() + if any(hkid == flow['context']['hkid'] for flow in active_flows): + return self.async_abort(reason='already_in_progress') + # The configuration number increases every time the characteristic map # needs updating. Some devices use a slightly off-spec name so handle # both cases. @@ -190,7 +199,11 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): self.model = model self.hkid = hkid - return await self.async_step_pair() + + # We want to show the pairing form - but don't call async_step_pair + # directly as it has side effects (will ask the device to show a + # pairing code) + return self._async_step_pair_show_form() async def async_import_legacy_pairing(self, discovery_props, pairing_data): """Migrate a legacy pairing to config entries.""" @@ -217,45 +230,91 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): """Pair with a new HomeKit accessory.""" import homekit # pylint: disable=import-error + # If async_step_pair is called with no pairing code then we do the M1 + # phase of pairing. If this is successful the device enters pairing + # mode. + + # If it doesn't have a screen then the pin is static. + + # If it has a display it will display a pin on that display. In + # this case the code is random. So we have to call the start_pairing + # API before the user can enter a pin. But equally we don't want to + # call start_pairing when the device is discovered, only when they + # click on 'Configure' in the UI. + + # start_pairing will make the device show its pin and return a + # callable. We call the callable with the pin that the user has typed + # in. + errors = {} if pair_info: code = pair_info['pairing_code'] - controller = homekit.Controller() try: await self.hass.async_add_executor_job( - controller.perform_pairing, self.hkid, self.hkid, code + self.finish_pairing, code ) - pairing = controller.pairings.get(self.hkid) + pairing = self.controller.pairings.get(self.hkid) if pairing: return await self._entry_from_accessory( pairing) errors['pairing_code'] = 'unable_to_pair' except homekit.AuthenticationError: + # PairSetup M4 - SRP proof failed + # PairSetup M6 - Ed25519 signature verification failed + # PairVerify M4 - Decryption failed + # PairVerify M4 - Device not recognised + # PairVerify M4 - Ed25519 signature verification failed errors['pairing_code'] = 'authentication_error' except homekit.UnknownError: + # An error occured on the device whilst performing this + # operation. errors['pairing_code'] = 'unknown_error' - except homekit.MaxTriesError: - errors['pairing_code'] = 'max_tries_error' - except homekit.BusyError: - errors['pairing_code'] = 'busy_error' except homekit.MaxPeersError: + # The device can't pair with any more accessories. errors['pairing_code'] = 'max_peers_error' except homekit.AccessoryNotFoundError: + # Can no longer find the device on the network return self.async_abort(reason='accessory_not_found_error') - except homekit.UnavailableError: - return self.async_abort(reason='already_paired') except Exception: # pylint: disable=broad-except _LOGGER.exception( "Pairing attempt failed with an unhandled exception" ) errors['pairing_code'] = 'pairing_failed' + start_pairing = self.controller.start_pairing + try: + self.finish_pairing = await self.hass.async_add_executor_job( + start_pairing, self.hkid, self.hkid + ) + except homekit.BusyError: + # Already performing a pair setup operation with a different + # controller + errors['pairing_code'] = 'busy_error' + except homekit.MaxTriesError: + # The accessory has received more than 100 unsuccessful auth + # attempts. + errors['pairing_code'] = 'max_tries_error' + except homekit.UnavailableError: + # The accessory is already paired - cannot try to pair again. + return self.async_abort(reason='already_paired') + except homekit.AccessoryNotFoundError: + # Can no longer find the device on the network + return self.async_abort(reason='accessory_not_found_error') + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Pairing attempt failed with an unhandled exception" + ) + errors['pairing_code'] = 'pairing_failed' + + return self._async_step_pair_show_form(errors) + + def _async_step_pair_show_form(self, errors=None): return self.async_show_form( step_id='pair', - errors=errors, + errors=errors or {}, data_schema=vol.Schema({ vol.Required('pairing_code'): vol.All(str, vol.Strip), }) @@ -263,13 +322,26 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): async def _entry_from_accessory(self, pairing): """Return a config entry from an initialized bridge.""" - accessories = await self.hass.async_add_executor_job( - pairing.list_accessories_and_characteristics - ) + # The bulk of the pairing record is stored on the config entry. + # A specific exception is the 'accessories' key. This is more + # volatile. We do cache it, but not against the config entry. + # So copy the pairing data and mutate the copy. + pairing_data = pairing.pairing_data.copy() + + # Use the accessories data from the pairing operation if it is + # available. Otherwise request a fresh copy from the API. + # This removes the 'accessories' key from pairing_data at + # the same time. + accessories = pairing_data.pop('accessories', None) + if not accessories: + accessories = await self.hass.async_add_executor_job( + pairing.list_accessories_and_characteristics + ) + bridge_info = get_bridge_information(accessories) name = get_accessory_name(bridge_info) return self.async_create_entry( title=name, - data=pairing.pairing_data, + data=pairing_data, ) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index af438c68164..d0fc99de0d7 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -1,14 +1,8 @@ """Helpers for managing a pairing with a HomeKit accessory or bridge.""" import asyncio import logging -import os -from homeassistant.helpers import discovery - -from .const import ( - CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, KNOWN_DEVICES, - PAIRING_FILE, HOMEKIT_DIR, ENTITY_MAP -) +from .const import HOMEKIT_ACCESSORY_DISPATCH, ENTITY_MAP RETRY_INTERVAL = 60 # seconds @@ -53,75 +47,69 @@ def get_accessory_name(accessory_info): class HKDevice(): """HomeKit device.""" - def __init__(self, hass, host, port, model, hkid, config_num, config): + def __init__(self, hass, config_entry, pairing_data): """Initialise a generic HomeKit device.""" - _LOGGER.info("Setting up Homekit device %s", model) - self.hass = hass - self.controller = hass.data[CONTROLLER] + from homekit.controller.ip_implementation import IpPairing + + self.hass = hass + self.config_entry = config_entry + + # We copy pairing_data because homekit_python may mutate it, but we + # don't want to mutate a dict owned by a config entry. + self.pairing_data = pairing_data.copy() + + self.pairing = IpPairing(self.pairing_data) - self.host = host - self.port = port - self.model = model - self.hkid = hkid - self.config_num = config_num - self.config = config - self.configurator = hass.components.configurator self.accessories = {} + self.config_num = 0 + + # A list of callbacks that turn HK service metadata into entities + self.listeners = [] + + # The platorms we have forwarded the config entry so far. If a new + # accessory is added to a bridge we may have to load additional + # platforms. We don't want to load all platforms up front if its just + # a lightbulb. And we dont want to forward a config entry twice + # (triggers a Config entry already set up error) + self.platforms = set() # This just tracks aid/iid pairs so we know if a HK service has been # mapped to a HA entity. self.entities = [] - self.pairing_lock = asyncio.Lock(loop=hass.loop) + # There are multiple entities sharing a single connection - only + # allow one entity to use pairing at once. + self.pairing_lock = asyncio.Lock() - self.pairing = self.controller.pairings.get(hkid) - - hass.data[KNOWN_DEVICES][hkid] = self - - def setup(self): + async def async_setup(self): """Prepare to use a paired HomeKit device in homeassistant.""" - if self.pairing is None: - self.configure() - return - - self.pairing.pairing_data['AccessoryIP'] = self.host - self.pairing.pairing_data['AccessoryPort'] = self.port - cache = self.hass.data[ENTITY_MAP].get_map(self.unique_id) - if not cache or cache['config_num'] < self.config_num: - return self.refresh_entity_map(self.config_num) + if not cache: + return await self.async_refresh_entity_map(self.config_num) self.accessories = cache['accessories'] + self.config_num = cache['config_num'] # Ensure the Pairing object has access to the latest version of the # entity map. self.pairing.pairing_data['accessories'] = self.accessories + self.async_load_platforms() + self.add_entities() return True - def refresh_entity_map(self, config_num): - """ - Handle setup of a HomeKit accessory. - - The sync version will be removed when homekit_controller migrates to - config flow. - """ - self.hass.add_job( - self.async_refresh_entity_map, - config_num, - ) - async def async_refresh_entity_map(self, config_num): """Handle setup of a HomeKit accessory.""" # pylint: disable=import-error from homekit.exceptions import AccessoryDisconnectedError try: - self.accessories = await self.hass.async_add_executor_job( - self.pairing.list_accessories_and_characteristics, - ) + async with self.pairing_lock: + self.accessories = await self.hass.async_add_executor_job( + self.pairing.list_accessories_and_characteristics + ) except AccessoryDisconnectedError: # If we fail to refresh this data then we will naturally retry # later when Bonjour spots c# is still not up to date. @@ -139,94 +127,62 @@ class HKDevice(): # aid/iid to GATT characteristics. So push it to there as well. self.pairing.pairing_data['accessories'] = self.accessories - # Register add new entities that are available - await self.hass.async_add_executor_job(self.add_entities) + self.async_load_platforms() + + # Register and add new entities that are available + self.add_entities() return True + def add_listener(self, add_entities_cb): + """Add a callback to run when discovering new entities.""" + self.listeners.append(add_entities_cb) + self._add_new_entities([add_entities_cb]) + def add_entities(self): """Process the entity map and create HA entities.""" - # pylint: disable=import-error + self._add_new_entities(self.listeners) + + def _add_new_entities(self, callbacks): from homekit.model.services import ServicesTypes for accessory in self.accessories: aid = accessory['aid'] for service in accessory['services']: iid = service['iid'] + stype = ServicesTypes.get_short(service['type'].upper()) + service['stype'] = stype + if (aid, iid) in self.entities: # Don't add the same entity again continue - devtype = ServicesTypes.get_short(service['type']) - _LOGGER.debug("Found %s", devtype) - service_info = {'serial': self.hkid, - 'aid': aid, - 'iid': service['iid'], - 'model': self.model, - 'device-type': devtype} - component = HOMEKIT_ACCESSORY_DISPATCH.get(devtype, None) - if component is not None: - discovery.load_platform(self.hass, component, DOMAIN, - service_info, self.config) - self.entities.append((aid, iid)) + for listener in callbacks: + if listener(aid, service): + self.entities.append((aid, iid)) + break - def device_config_callback(self, callback_data): - """Handle initial pairing.""" - import homekit # pylint: disable=import-error - code = callback_data.get('code').strip() - try: - self.controller.perform_pairing(self.hkid, self.hkid, code) - except homekit.UnavailableError: - error_msg = "This accessory is already paired to another device. \ - Please reset the accessory and try again." - _configurator = self.hass.data[DOMAIN+self.hkid] - self.configurator.notify_errors(_configurator, error_msg) - return - except homekit.AuthenticationError: - error_msg = "Incorrect HomeKit code for {}. Please check it and \ - try again.".format(self.model) - _configurator = self.hass.data[DOMAIN+self.hkid] - self.configurator.notify_errors(_configurator, error_msg) - return - except homekit.UnknownError: - error_msg = "Received an unknown error. Please file a bug." - _configurator = self.hass.data[DOMAIN+self.hkid] - self.configurator.notify_errors(_configurator, error_msg) - raise + def async_load_platforms(self): + """Load any platforms needed by this HomeKit device.""" + from homekit.model.services import ServicesTypes - self.pairing = self.controller.pairings.get(self.hkid) - if self.pairing is not None: - pairing_dir = os.path.join( - self.hass.config.path(), - HOMEKIT_DIR, - ) - if not os.path.exists(pairing_dir): - os.makedirs(pairing_dir) - pairing_file = os.path.join( - pairing_dir, - PAIRING_FILE, - ) - self.controller.save_data(pairing_file) - _configurator = self.hass.data[DOMAIN+self.hkid] - self.configurator.request_done(_configurator) - self.setup() - else: - error_msg = "Unable to pair, please try again" - _configurator = self.hass.data[DOMAIN+self.hkid] - self.configurator.notify_errors(_configurator, error_msg) + for accessory in self.accessories: + for service in accessory['services']: + stype = ServicesTypes.get_short(service['type'].upper()) + if stype not in HOMEKIT_ACCESSORY_DISPATCH: + continue - def configure(self): - """Obtain the pairing code for a HomeKit device.""" - description = "Please enter the HomeKit code for your {}".format( - self.model) - self.hass.data[DOMAIN+self.hkid] = \ - self.configurator.request_config(self.model, - self.device_config_callback, - description=description, - submit_caption="submit", - fields=[{'id': 'code', - 'name': 'HomeKit code', - 'type': 'string'}]) + platform = HOMEKIT_ACCESSORY_DISPATCH[stype] + if platform in self.platforms: + continue + + self.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, + platform, + ) + ) + self.platforms.add(platform) async def get_characteristics(self, *args, **kwargs): """Read latest state from homekit accessory.""" @@ -261,4 +217,14 @@ class HKDevice(): This id is random and will change if a device undergoes a hard reset. """ - return self.hkid + return self.pairing_data['AccessoryPairingID'] + + @property + def connection_info(self): + """Return accessory information for the main accessory.""" + return get_bridge_information(self.accessories) + + @property + def name(self): + """Name of the bridge accessory.""" + return get_accessory_name(self.connection_info) or self.unique_id diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index bd466d074d0..7f3761d33a4 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -35,18 +35,30 @@ CURRENT_WINDOW_STATE_MAP = { } -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up HomeKit Cover support.""" - if discovery_info is None: - return - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass - if discovery_info['device-type'] == 'garage-door-opener': - add_entities([HomeKitGarageDoorCover(accessory, discovery_info)], - True) - else: - add_entities([HomeKitWindowCover(accessory, discovery_info)], - True) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit covers.""" + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + info = {'aid': aid, 'iid': service['iid']} + if service['stype'] == 'garage-door-opener': + async_add_entities([HomeKitGarageDoorCover(conn, info)], True) + return True + + if service['stype'] in ('window-covering', 'window'): + async_add_entities([HomeKitWindowCover(conn, info)], True) + return True + + return False + + conn.add_listener(async_add_service) class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice): diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index a139b1f2932..248412c91a3 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -10,11 +10,25 @@ from . import KNOWN_DEVICES, HomeKitEntity _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit lighting.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitLight(accessory, discovery_info)], True) +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit lightbulb.""" + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service['stype'] != 'lightbulb': + return False + info = {'aid': aid, 'iid': service['iid']} + async_add_entities([HomeKitLight(conn, info)], True) + return True + + conn.add_listener(async_add_service) class HomeKitLight(HomeKitEntity, Light): diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index 67de2bfaf3f..1449f265245 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -24,12 +24,25 @@ TARGET_STATE_MAP = { } -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit Lock support.""" - if discovery_info is None: - return - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitLock(accessory, discovery_info)], True) +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit lock.""" + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service['stype'] != 'lock-mechanism': + return False + info = {'aid': aid, 'iid': service['iid']} + async_add_entities([HomeKitLock(conn, info)], True) + return True + + conn.add_listener(async_add_service) class HomeKitLock(HomeKitEntity, LockDevice): diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index c1b923a5677..62dbf3740a3 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -1,11 +1,13 @@ { "domain": "homekit_controller", "name": "Homekit controller", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/homekit_controller", "requirements": [ "homekit[IP]==0.14.0" ], - "dependencies": ["configurator"], + "dependencies": [], + "zeroconf": ["_hap._tcp.local."], "codeowners": [ "@Jc2k" ] diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index b377da80142..9ffa6c6b597 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -3,29 +3,43 @@ from homeassistant.const import TEMP_CELSIUS from . import KNOWN_DEVICES, HomeKitEntity -HUMIDITY_ICON = 'mdi-water-percent' -TEMP_C_ICON = "mdi-temperature-celsius" -BRIGHTNESS_ICON = "mdi-brightness-6" +HUMIDITY_ICON = 'mdi:water-percent' +TEMP_C_ICON = "mdi:thermometer" +BRIGHTNESS_ICON = "mdi:brightness-6" UNIT_PERCENT = "%" UNIT_LUX = "lux" -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit sensor support.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - devtype = discovery_info['device-type'] +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit covers.""" + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + devtype = service['stype'] + info = {'aid': aid, 'iid': service['iid']} if devtype == 'humidity': - add_entities( - [HomeKitHumiditySensor(accessory, discovery_info)], True) - elif devtype == 'temperature': - add_entities( - [HomeKitTemperatureSensor(accessory, discovery_info)], True) - elif devtype == 'light': - add_entities( - [HomeKitLightSensor(accessory, discovery_info)], True) + async_add_entities([HomeKitHumiditySensor(conn, info)], True) + return True + + if devtype == 'temperature': + async_add_entities([HomeKitTemperatureSensor(conn, info)], True) + return True + + if devtype == 'light': + async_add_entities([HomeKitLightSensor(conn, info)], True) + return True + + return False + + conn.add_listener(async_add_service) class HomeKitHumiditySensor(HomeKitEntity): diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index eceaa624b0f..b51dcb1f6d8 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -33,7 +33,8 @@ "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.", "already_configured": "Accessory is already configured with this controller.", "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed.", - "accessory_not_found_error": "Cannot add pairing as device can no longer be found." + "accessory_not_found_error": "Cannot add pairing as device can no longer be found.", + "already_in_progress": "Config flow for device is already in progress." } } } diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index c09502373a6..670ddd4db5b 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -10,11 +10,25 @@ OUTLET_IN_USE = "outlet_in_use" _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit switch support.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitSwitch(accessory, discovery_info)], True) +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit lock.""" + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service['stype'] not in ('switch', 'outlet'): + return False + info = {'aid': aid, 'iid': service['iid']} + async_add_entities([HomeKitSwitch(conn, info)], True) + return True + + conn.add_listener(async_add_service) class HomeKitSwitch(HomeKitEntity, SwitchDevice): diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 578fae064f8..013f1eab679 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -1,5 +1,5 @@ """Support for HomeMatic devices.""" -from datetime import timedelta +from datetime import timedelta, datetime from functools import partial import logging @@ -27,12 +27,14 @@ DISCOVER_BINARY_SENSORS = 'homematic.binary_sensor' DISCOVER_COVER = 'homematic.cover' DISCOVER_CLIMATE = 'homematic.climate' DISCOVER_LOCKS = 'homematic.locks' +DISCOVER_BATTERY = 'homematic.battery' ATTR_DISCOVER_DEVICES = 'devices' ATTR_PARAM = 'param' ATTR_CHANNEL = 'channel' ATTR_ADDRESS = 'address' ATTR_VALUE = 'value' +ATTR_VALUE_TYPE = 'value_type' ATTR_INTERFACE = 'interface' ATTR_ERRORCODE = 'error' ATTR_MESSAGE = 'message' @@ -41,6 +43,10 @@ ATTR_TIME = 'time' ATTR_UNIQUE_ID = 'unique_id' ATTR_PARAMSET_KEY = 'paramset_key' ATTR_PARAMSET = 'paramset' +ATTR_DISCOVERY_TYPE = 'discovery_type' +ATTR_LOW_BAT = 'LOW_BAT' +ATTR_LOWBAT = 'LOWBAT' + EVENT_KEYPRESS = 'homematic.keypress' EVENT_IMPULSE = 'homematic.impulse' @@ -230,6 +236,10 @@ SCHEMA_SERVICE_SET_DEVICE_VALUE = vol.Schema({ vol.Required(ATTR_CHANNEL): vol.Coerce(int), vol.Required(ATTR_PARAM): vol.All(cv.string, vol.Upper), vol.Required(ATTR_VALUE): cv.match_all, + vol.Optional(ATTR_VALUE_TYPE): vol.In([ + 'boolean', 'dateTime.iso8601', + 'double', 'int', 'string' + ]), vol.Optional(ATTR_INTERFACE): cv.string, }) @@ -374,6 +384,22 @@ def setup(hass, config): channel = service.data.get(ATTR_CHANNEL) param = service.data.get(ATTR_PARAM) value = service.data.get(ATTR_VALUE) + value_type = service.data.get(ATTR_VALUE_TYPE) + + # Convert value into correct XML-RPC Type. + # https://docs.python.org/3/library/xmlrpc.client.html#xmlrpc.client.ServerProxy + if value_type: + if value_type == 'int': + value = int(value) + elif value_type == 'double': + value = float(value) + elif value_type == 'boolean': + value = bool(value) + elif value_type == 'dateTime.iso8601': + value = datetime.strptime(value, '%Y%m%dT%H:%M:%S') + else: + # Default is 'string' + value = str(value) # Device not found hmdevice = _device_from_servicecall(hass, service) @@ -460,7 +486,8 @@ def _system_callback_handler(hass, config, src, *args): ('binary_sensor', DISCOVER_BINARY_SENSORS), ('sensor', DISCOVER_SENSORS), ('climate', DISCOVER_CLIMATE), - ('lock', DISCOVER_LOCKS)): + ('lock', DISCOVER_LOCKS), + ('binary_sensor', DISCOVER_BATTERY)): # Get all devices of a specific type found_devices = _get_devices( hass, discovery_type, addresses, interface) @@ -469,7 +496,8 @@ def _system_callback_handler(hass, config, src, *args): # they are setup in HASS and a discovery event is fired if found_devices: discovery.load_platform(hass, component_name, DOMAIN, { - ATTR_DISCOVER_DEVICES: found_devices + ATTR_DISCOVER_DEVICES: found_devices, + ATTR_DISCOVERY_TYPE: discovery_type, }, config) # Homegear error message @@ -492,7 +520,8 @@ def _get_devices(hass, discovery_type, keys, interface): metadata = {} # Class not supported by discovery type - if class_name not in HM_DEVICE_TYPES[discovery_type]: + if discovery_type != DISCOVER_BATTERY and \ + class_name not in HM_DEVICE_TYPES[discovery_type]: continue # Load metadata needed to generate a parameter list @@ -500,6 +529,15 @@ def _get_devices(hass, discovery_type, keys, interface): metadata.update(device.SENSORNODE) elif discovery_type == DISCOVER_BINARY_SENSORS: metadata.update(device.BINARYNODE) + elif discovery_type == DISCOVER_BATTERY: + if ATTR_LOWBAT in device.ATTRIBUTENODE: + metadata.update( + {ATTR_LOWBAT: device.ATTRIBUTENODE[ATTR_LOWBAT]}) + elif ATTR_LOW_BAT in device.ATTRIBUTENODE: + metadata.update( + {ATTR_LOW_BAT: device.ATTRIBUTENODE[ATTR_LOW_BAT]}) + else: + continue else: metadata.update({None: device.ELEMENT}) diff --git a/homeassistant/components/homematic/binary_sensor.py b/homeassistant/components/homematic/binary_sensor.py index dfd7b7a72bd..9d47f74df92 100644 --- a/homeassistant/components/homematic/binary_sensor.py +++ b/homeassistant/components/homematic/binary_sensor.py @@ -2,7 +2,9 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.const import STATE_UNKNOWN +from homeassistant.components.homematic import ( + ATTR_DISCOVERY_TYPE, DISCOVER_BATTERY) +from homeassistant.const import DEVICE_CLASS_BATTERY from . import ATTR_DISCOVER_DEVICES, HMDevice @@ -31,8 +33,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMBinarySensor(conf) - devices.append(new_device) + if discovery_info[ATTR_DISCOVERY_TYPE] == DISCOVER_BATTERY: + devices.append(HMBatterySensor(conf)) + else: + devices.append(HMBinarySensor(conf)) add_entities(devices) @@ -59,4 +63,24 @@ class HMBinarySensor(HMDevice, BinarySensorDevice): """Generate the data dictionary (self._data) from metadata.""" # Add state to data struct if self._state: - self._data.update({self._state: STATE_UNKNOWN}) + self._data.update({self._state: None}) + + +class HMBatterySensor(HMDevice, BinarySensorDevice): + """Representation of an HomeMatic low battery sensor.""" + + @property + def device_class(self): + """Return battery as a device class.""" + return DEVICE_CLASS_BATTERY + + @property + def is_on(self): + """Return True if battery is low.""" + return bool(self._hm_get_state()) + + def _init_data_struct(self): + """Generate the data dictionary (self._data) from metadata.""" + # Add state to data struct + if self._state: + self._data.update({self._state: None}) diff --git a/homeassistant/components/homematicip_cloud/.translations/no.json b/homeassistant/components/homematicip_cloud/.translations/no.json index 28cfc502aba..9a4dd424bee 100644 --- a/homeassistant/components/homematicip_cloud/.translations/no.json +++ b/homeassistant/components/homematicip_cloud/.translations/no.json @@ -14,7 +14,7 @@ "step": { "init": { "data": { - "hapid": "Tilgangspunkt ID (SGTIN)", + "hapid": "Tilgangspunkt-ID (SGTIN)", "name": "Navn (valgfritt, brukes som prefiks for alle enheter)", "pin": "PIN kode (valgfritt)" }, diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 19d35c47cdb..b006ec80686 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -3,14 +3,18 @@ import logging from homematicip.aio.device import ( AsyncDevice, AsyncMotionDetectorIndoor, AsyncMotionDetectorOutdoor, - AsyncMotionDetectorPushButton, AsyncRotaryHandleSensor, - AsyncShutterContact, AsyncSmokeDetector, AsyncWaterSensor, - AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) + AsyncMotionDetectorPushButton, AsyncPresenceDetectorIndoor, + AsyncRotaryHandleSensor, AsyncShutterContact, AsyncSmokeDetector, + AsyncWaterSensor, AsyncWeatherSensor, AsyncWeatherSensorPlus, + AsyncWeatherSensorPro) from homematicip.aio.group import AsyncSecurityGroup, AsyncSecurityZoneGroup from homematicip.aio.home import AsyncHome from homematicip.base.enums import SmokeDetectorAlarmType, WindowState -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY, DEVICE_CLASS_DOOR, DEVICE_CLASS_LIGHT, + DEVICE_CLASS_MOISTURE, DEVICE_CLASS_MOTION, DEVICE_CLASS_PRESENCE, + DEVICE_CLASS_SAFETY, DEVICE_CLASS_SMOKE, BinarySensorDevice) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -47,6 +51,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, AsyncMotionDetectorOutdoor, AsyncMotionDetectorPushButton)): devices.append(HomematicipMotionDetector(home, device)) + if isinstance(device, AsyncPresenceDetectorIndoor): + devices.append(HomematicipPresenceDetector(home, device)) if isinstance(device, AsyncSmokeDetector): devices.append(HomematicipSmokeDetector(home, device)) if isinstance(device, AsyncWaterSensor): @@ -77,7 +83,7 @@ class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorDevice): @property def device_class(self) -> str: """Return the class of this sensor.""" - return 'door' + return DEVICE_CLASS_DOOR @property def is_on(self) -> bool: @@ -95,7 +101,7 @@ class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorDevice): @property def device_class(self) -> str: """Return the class of this sensor.""" - return 'motion' + return DEVICE_CLASS_MOTION @property def is_on(self) -> bool: @@ -105,13 +111,30 @@ class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorDevice): return self._device.motionDetected +class HomematicipPresenceDetector(HomematicipGenericDevice, + BinarySensorDevice): + """Representation of a HomematicIP Cloud presence detector.""" + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_PRESENCE + + @property + def is_on(self) -> bool: + """Return true if presence is detected.""" + if hasattr(self._device, 'sabotage') and self._device.sabotage: + return True + return self._device.presenceDetected + + class HomematicipSmokeDetector(HomematicipGenericDevice, BinarySensorDevice): """Representation of a HomematicIP Cloud smoke detector.""" @property def device_class(self) -> str: """Return the class of this sensor.""" - return 'smoke' + return DEVICE_CLASS_SMOKE @property def is_on(self) -> bool: @@ -126,7 +149,7 @@ class HomematicipWaterDetector(HomematicipGenericDevice, BinarySensorDevice): @property def device_class(self) -> str: """Return the class of this sensor.""" - return 'moisture' + return DEVICE_CLASS_MOISTURE @property def is_on(self) -> bool: @@ -162,7 +185,7 @@ class HomematicipRainSensor(HomematicipGenericDevice, BinarySensorDevice): @property def device_class(self) -> str: """Return the class of this sensor.""" - return 'moisture' + return DEVICE_CLASS_MOISTURE @property def is_on(self) -> bool: @@ -180,7 +203,7 @@ class HomematicipSunshineSensor(HomematicipGenericDevice, BinarySensorDevice): @property def device_class(self) -> str: """Return the class of this sensor.""" - return 'light' + return DEVICE_CLASS_LIGHT @property def is_on(self) -> bool: @@ -208,7 +231,7 @@ class HomematicipBatterySensor(HomematicipGenericDevice, BinarySensorDevice): @property def device_class(self) -> str: """Return the class of this sensor.""" - return 'battery' + return DEVICE_CLASS_BATTERY @property def is_on(self) -> bool: @@ -229,7 +252,7 @@ class HomematicipSecurityZoneSensorGroup(HomematicipGenericDevice, @property def device_class(self) -> str: """Return the class of this sensor.""" - return 'safety' + return DEVICE_CLASS_SAFETY @property def available(self) -> bool: diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 3170fc149d5..66695bb01c7 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -1,6 +1,8 @@ """Support for HomematicIP Cloud climate devices.""" import logging +from homematicip.aio.device import ( + AsyncHeatingThermostat, AsyncHeatingThermostatCompact) from homematicip.aio.group import AsyncHeatingGroup from homematicip.aio.home import AsyncHome @@ -48,6 +50,9 @@ class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice): def __init__(self, home: AsyncHome, device) -> None: """Initialize heating group.""" device.modelType = 'Group-Heating' + self._simple_heating = None + if device.actualTemperature is None: + self._simple_heating = _get_first_heating_thermostat(device) super().__init__(home, device) @property @@ -68,6 +73,8 @@ class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice): @property def current_temperature(self) -> float: """Return the current temperature.""" + if self._simple_heating: + return self._simple_heating.valveActualTemperature return self._device.actualTemperature @property @@ -96,3 +103,12 @@ class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice): if temperature is None: return await self._device.set_point_temperature(temperature) + + +def _get_first_heating_thermostat(heating_group: AsyncHeatingGroup): + """Return the first HeatingThermostat from a HeatingGroup.""" + for device in heating_group.devices: + if isinstance(device, (AsyncHeatingThermostat, + AsyncHeatingThermostatCompact)): + return device + return None diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index b3731bc9f1a..8bbbb8f41b6 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -180,7 +180,7 @@ class HomematicipHAP: try: self._retry_task = self.hass.async_create_task(asyncio.sleep( - retry_delay, loop=self.hass.loop)) + retry_delay)) await self._retry_task except asyncio.CancelledError: break diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 030b4d5b79b..6ba04bfe3c0 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -1,6 +1,7 @@ { "domain": "homematicip_cloud", "name": "Homematicip cloud", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/homematicip_cloud", "requirements": [ "homematicip==0.10.7" diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 3d91b25c2bd..b3e23bde2be 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -6,7 +6,7 @@ from homematicip.aio.device import ( AsyncHeatingThermostat, AsyncHeatingThermostatCompact, AsyncLightSensor, AsyncMotionDetectorIndoor, AsyncMotionDetectorOutdoor, AsyncMotionDetectorPushButton, AsyncPlugableSwitchMeasuring, - AsyncTemperatureHumiditySensorDisplay, + AsyncPresenceDetectorIndoor, AsyncTemperatureHumiditySensorDisplay, AsyncTemperatureHumiditySensorOutdoor, AsyncTemperatureHumiditySensorWithoutDisplay, AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) @@ -55,6 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, if isinstance(device, (AsyncLightSensor, AsyncMotionDetectorIndoor, AsyncMotionDetectorOutdoor, AsyncMotionDetectorPushButton, + AsyncPresenceDetectorIndoor, AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro)): diff --git a/homeassistant/components/hook/switch.py b/homeassistant/components/hook/switch.py index 7a11c1dd8b7..abe2040b091 100644 --- a/homeassistant/components/hook/switch.py +++ b/homeassistant/components/hook/switch.py @@ -36,7 +36,7 @@ async def async_setup_platform(hass, config, async_add_entities, # If password is set in config, prefer it over token if username is not None and password is not None: try: - with async_timeout.timeout(TIMEOUT, loop=hass.loop): + with async_timeout.timeout(TIMEOUT): response = await websession.post( '{}{}'.format(HOOK_ENDPOINT, 'user/login'), data={ @@ -56,7 +56,7 @@ async def async_setup_platform(hass, config, async_add_entities, return False try: - with async_timeout.timeout(TIMEOUT, loop=hass.loop): + with async_timeout.timeout(TIMEOUT): response = await websession.get( '{}{}'.format(HOOK_ENDPOINT, 'device'), params={"token": token}) @@ -103,7 +103,7 @@ class HookSmartHome(SwitchDevice): try: _LOGGER.debug("Sending: %s", url) websession = async_get_clientsession(self.hass) - with async_timeout.timeout(TIMEOUT, loop=self.hass.loop): + with async_timeout.timeout(TIMEOUT): response = await websession.get( url, params={"token": self._token}) data = await response.json(content_type=None) diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index 5ab2b39baed..c8cd207da3e 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -484,15 +484,6 @@ class HTML5NotificationService(BaseNotificationService): payload[ATTR_DATA][ATTR_JWT] = add_jwt( timestamp, target, payload[ATTR_TAG], info[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH]) - import jwt - jwt_secret = info[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH] - jwt_exp = (datetime.fromtimestamp(timestamp) + - timedelta(days=JWT_VALID_DAYS)) - jwt_claims = {'exp': jwt_exp, 'nbf': timestamp, - 'iat': timestamp, ATTR_TARGET: target, - ATTR_TAG: payload[ATTR_TAG]} - jwt_token = jwt.encode(jwt_claims, jwt_secret).decode('utf-8') - payload[ATTR_DATA][ATTR_JWT] = jwt_token webpusher = WebPusher(info[ATTR_SUBSCRIPTION]) if self._vapid_prv and self._vapid_email: vapid_headers = create_vapid_headers( diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 92c41157a33..1cb610e71a6 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -3,7 +3,6 @@ from collections import defaultdict from datetime import datetime from ipaddress import ip_address import logging -import os from aiohttp.web import middleware from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized @@ -155,11 +154,10 @@ async def async_load_ip_bans_config(hass: HomeAssistant, path: str): """Load list of banned IPs from config file.""" ip_list = [] - if not os.path.isfile(path): - return ip_list - try: list_ = await hass.async_add_executor_job(load_yaml_config_file, path) + except FileNotFoundError: + return ip_list except HomeAssistantError as err: _LOGGER.error('Unable to load %s: %s', path, str(err)) return ip_list diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index 1ef70b5e022..419b62be2c6 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -1,4 +1,5 @@ """Provide CORS support for the HTTP component.""" +from aiohttp.web_urldispatcher import Resource, ResourceRoute from aiohttp.hdrs import ACCEPT, CONTENT_TYPE, ORIGIN, AUTHORIZATION from homeassistant.const import ( @@ -8,6 +9,7 @@ from homeassistant.core import callback ALLOWED_CORS_HEADERS = [ ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE, HTTP_HEADER_HA_AUTH, AUTHORIZATION] +VALID_CORS_TYPES = (Resource, ResourceRoute) @callback @@ -31,6 +33,9 @@ def setup_cors(app, origins): else: path = route + if not isinstance(path, VALID_CORS_TYPES): + return + path = path.canonical if path in cors_added: diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 2e096343b09..bfdc6f167aa 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -3,7 +3,7 @@ "name": "Huawei lte", "documentation": "https://www.home-assistant.io/components/huawei_lte", "requirements": [ - "huawei-lte-api==1.1.5" + "huawei-lte-api==1.2.0" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/hue/.translations/ca.json b/homeassistant/components/hue/.translations/ca.json index a37d4ef1518..078c4e75377 100644 --- a/homeassistant/components/hue/.translations/ca.json +++ b/homeassistant/components/hue/.translations/ca.json @@ -3,6 +3,7 @@ "abort": { "all_configured": "Tots els enlla\u00e7os Philips Hue ja estan configurats", "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.", "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", diff --git a/homeassistant/components/hue/.translations/en.json b/homeassistant/components/hue/.translations/en.json index cea8d8be10a..744efb1b15e 100644 --- a/homeassistant/components/hue/.translations/en.json +++ b/homeassistant/components/hue/.translations/en.json @@ -3,6 +3,7 @@ "abort": { "all_configured": "All Philips Hue bridges are already configured", "already_configured": "Bridge is already configured", + "already_in_progress": "Config flow for bridge is already in progress.", "cannot_connect": "Unable to connect to the bridge", "discover_timeout": "Unable to discover Hue bridges", "no_bridges": "No Philips Hue bridges discovered", diff --git a/homeassistant/components/hue/.translations/fr.json b/homeassistant/components/hue/.translations/fr.json index 5414bf01ea7..ddb647c18ed 100644 --- a/homeassistant/components/hue/.translations/fr.json +++ b/homeassistant/components/hue/.translations/fr.json @@ -3,6 +3,7 @@ "abort": { "all_configured": "Tous les ponts Philips Hue sont d\u00e9j\u00e0 configur\u00e9s", "already_configured": "Ce pont est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "Le flux de configuration pour le pont est d\u00e9j\u00e0 en cours.", "cannot_connect": "Connexion au pont impossible", "discover_timeout": "D\u00e9tection de ponts Philips Hue impossible", "no_bridges": "Aucun pont Philips Hue n'a \u00e9t\u00e9 d\u00e9couvert", diff --git a/homeassistant/components/hue/.translations/ru.json b/homeassistant/components/hue/.translations/ru.json index ce71fb670be..713e86f49b7 100644 --- a/homeassistant/components/hue/.translations/ru.json +++ b/homeassistant/components/hue/.translations/ru.json @@ -3,6 +3,7 @@ "abort": { "all_configured": "\u0412\u0441\u0435 Philips Hue \u0448\u043b\u044e\u0437\u044b \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b", "already_configured": "\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.", "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", diff --git a/homeassistant/components/hue/.translations/sl.json b/homeassistant/components/hue/.translations/sl.json index 7ad7a2e6ade..fc3142ba820 100644 --- a/homeassistant/components/hue/.translations/sl.json +++ b/homeassistant/components/hue/.translations/sl.json @@ -3,6 +3,7 @@ "abort": { "all_configured": "Vsi mostovi Philips Hue so \u017ee konfigurirani", "already_configured": "Most je \u017ee konfiguriran", + "already_in_progress": "Konfiguracijski tok za most je \u017ee v teku.", "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", diff --git a/homeassistant/components/hue/.translations/sv.json b/homeassistant/components/hue/.translations/sv.json index a7ffc7bacb2..b0b8ea3cbfa 100644 --- a/homeassistant/components/hue/.translations/sv.json +++ b/homeassistant/components/hue/.translations/sv.json @@ -3,6 +3,7 @@ "abort": { "all_configured": "Alla Philips Hue-bryggor \u00e4r redan konfigurerade", "already_configured": "Bryggan \u00e4r redan konfigurerad", + "already_in_progress": "Konfigurations fl\u00f6det f\u00f6r bryggan p\u00e5g\u00e5r redan.", "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", diff --git a/homeassistant/components/hue/.translations/zh-Hant.json b/homeassistant/components/hue/.translations/zh-Hant.json index eae4c09da49..a585cfd38c3 100644 --- a/homeassistant/components/hue/.translations/zh-Hant.json +++ b/homeassistant/components/hue/.translations/zh-Hant.json @@ -3,6 +3,7 @@ "abort": { "all_configured": "\u6240\u6709 Philips Hue Bridge \u7686\u5df2\u8a2d\u5b9a\u5b8c\u6210", "already_configured": "Bridge \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "Bridge \u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", "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", diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 25db031e6bf..6fa6bad2f47 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -60,6 +60,7 @@ class HueBridge: return False except CannotConnect: + LOGGER.error("Error connecting to the Hue bridge at %s", host) raise ConfigEntryNotReady except Exception: # pylint: disable=broad-except @@ -152,7 +153,7 @@ async def get_bridge(hass, host, username=None): ) try: - with async_timeout.timeout(5): + with async_timeout.timeout(10): # Create username if we don't have one if not username: await bridge.create_user('home-assistant') @@ -161,10 +162,8 @@ async def get_bridge(hass, host, username=None): return bridge except (aiohue.LinkButtonNotPressed, aiohue.Unauthorized): - LOGGER.warning("Connected to Hue at %s but not registered.", host) raise AuthenticationRequired except (asyncio.TimeoutError, aiohue.RequestError): - LOGGER.error("Error connecting to the Hue bridge at %s", host) raise CannotConnect except aiohue.AiohueException: LOGGER.exception('Unknown Hue linking error occurred') diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 89dc0b9aa67..9c81d144d1c 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -8,6 +8,7 @@ import async_timeout import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.ssdp import ATTR_MANUFACTURERURL from homeassistant.core import callback from homeassistant.helpers import aiohttp_client @@ -15,6 +16,8 @@ from .bridge import get_bridge from .const import DOMAIN, LOGGER from .errors import AuthenticationRequired, CannotConnect +HUE_MANUFACTURERURL = 'http://www.philips.com' + @callback def configured_hosts(hass): @@ -137,17 +140,25 @@ class HueFlowHandler(config_entries.ConfigFlow): errors=errors, ) - async def async_step_discovery(self, discovery_info): + async def async_step_ssdp(self, discovery_info): """Handle a discovered Hue bridge. - This flow is triggered by the discovery component. It will check if the + This flow is triggered by the SSDP component. It will check if the host is already configured and delegate to the import step if not. """ + if discovery_info[ATTR_MANUFACTURERURL] != HUE_MANUFACTURERURL: + return self.async_abort(reason='not_hue_bridge') + # Filter out emulated Hue if "HASS Bridge" in discovery_info.get('name', ''): return self.async_abort(reason='already_configured') - host = discovery_info.get('host') + # pylint: disable=unsupported-assignment-operation + host = self.context['host'] = discovery_info.get('host') + + if any(host == flow['context']['host'] + for flow in self._async_in_progress()): + return self.async_abort(reason='already_in_progress') if host in configured_hosts(self.hass): return self.async_abort(reason='already_configured') diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 54a3a11a189..d16988529b1 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -1,10 +1,16 @@ { "domain": "hue", "name": "Philips Hue", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/hue", "requirements": [ "aiohue==1.9.1" ], + "ssdp": { + "manufacturer": [ + "Royal Philips Electronics" + ] + }, "dependencies": [], "codeowners": [ "@balloob" diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index f8873894a01..78b990d5f42 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -23,7 +23,9 @@ "all_configured": "All Philips Hue bridges are already configured", "unknown": "Unknown error occurred", "cannot_connect": "Unable to connect to the bridge", - "already_configured": "Bridge is already configured" + "already_configured": "Bridge is already configured", + "already_in_progress": "Config flow for bridge is already in progress.", + "not_hue_bridge": "Not a Hue bridge" } } } diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 908fe5ecf90..89de6e57f6e 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -6,14 +6,17 @@ import os import voluptuous as vol from homeassistant.const import CONF_USERNAME, CONF_PASSWORD -from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, DOMAIN, ATTR_ATTRIBUTES, ENTITY_ID_FORMAT, DeviceScanner) -from homeassistant.components.zone.zone import active_zone +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.device_tracker.const import ( + DOMAIN, ATTR_ATTRIBUTES, ENTITY_ID_FORMAT) +from homeassistant.components.device_tracker.legacy import DeviceScanner +from homeassistant.components.zone import async_active_zone from homeassistant.helpers.event import track_utc_time_change import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify import homeassistant.util.dt as dt_util from homeassistant.util.location import distance +from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) @@ -328,7 +331,10 @@ class Icloud(DeviceScanner): def determine_interval(self, devicename, latitude, longitude, battery): """Calculate new interval.""" - currentzone = active_zone(self.hass, latitude, longitude) + currentzone = run_callback_threadsafe( + self.hass.loop, + async_active_zone, self.hass, latitude, longitude + ).result() if ((currentzone is not None and currentzone == self._overridestates.get(devicename)) or @@ -470,10 +476,13 @@ class Icloud(DeviceScanner): devicestate = self.hass.states.get(devid) if interval is not None: if devicestate is not None: - self._overridestates[device] = active_zone( + self._overridestates[device] = run_callback_threadsafe( + self.hass.loop, + async_active_zone, self.hass, float(devicestate.attributes.get('latitude', 0)), - float(devicestate.attributes.get('longitude', 0))) + float(devicestate.attributes.get('longitude', 0)) + ).result() if self._overridestates[device] is None: self._overridestates[device] = 'away' self._intervals[device] = interval diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 6b5934702aa..e6926ff0fb5 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.helpers import config_entry_flow import homeassistant.helpers.config_validation as cv +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -21,8 +22,6 @@ ATTR_VALUE3 = 'value3' CONF_KEY = 'key' -DOMAIN = 'ifttt' - SERVICE_TRIGGER = 'trigger' SERVICE_TRIGGER_SCHEMA = vol.Schema({ @@ -108,13 +107,3 @@ async def async_unload_entry(hass, entry): # pylint: disable=invalid-name async_remove_entry = config_entry_flow.webhook_async_remove_entry - - -config_entry_flow.register_webhook_flow( - DOMAIN, - 'IFTTT Webhook', - { - 'applet_url': 'https://ifttt.com/maker_webhooks', - 'docs_url': 'https://www.home-assistant.io/components/ifttt/' - } -) diff --git a/homeassistant/components/ifttt/config_flow.py b/homeassistant/components/ifttt/config_flow.py new file mode 100644 index 00000000000..887a5c88013 --- /dev/null +++ b/homeassistant/components/ifttt/config_flow.py @@ -0,0 +1,13 @@ +"""Config flow for IFTTT.""" +from homeassistant.helpers import config_entry_flow +from .const import DOMAIN + + +config_entry_flow.register_webhook_flow( + DOMAIN, + 'IFTTT Webhook', + { + 'applet_url': 'https://ifttt.com/maker_webhooks', + 'docs_url': 'https://www.home-assistant.io/components/ifttt/' + } +) diff --git a/homeassistant/components/ifttt/const.py b/homeassistant/components/ifttt/const.py new file mode 100644 index 00000000000..03b948fc83a --- /dev/null +++ b/homeassistant/components/ifttt/const.py @@ -0,0 +1,3 @@ +"""Const for IFTTT.""" + +DOMAIN = "ifttt" diff --git a/homeassistant/components/ifttt/manifest.json b/homeassistant/components/ifttt/manifest.json index 007e0870023..58490569e65 100644 --- a/homeassistant/components/ifttt/manifest.json +++ b/homeassistant/components/ifttt/manifest.json @@ -1,6 +1,7 @@ { "domain": "ifttt", "name": "Ifttt", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/ifttt", "requirements": [ "pyfttt==0.3" diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index ce49ebf932e..95ab0245dbb 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -77,7 +77,7 @@ async def async_setup(hass, config): entity.async_update_ha_state(True)) if update_tasks: - await asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks) hass.services.async_register( DOMAIN, SERVICE_SCAN, async_scan_service, diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index edff8c8299f..8aaa8e7e19d 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -44,7 +44,8 @@ async def async_setup(hass, hass_config): exc_info=True) return False - hass.async_create_task(async_load_platform( - hass, 'water_heater', DOMAIN, {}, hass_config)) + for platform in ['water_heater', 'climate']: + hass.async_create_task(async_load_platform( + hass, platform, DOMAIN, {}, hass_config)) return True diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py new file mode 100644 index 00000000000..fa42ced32c2 --- /dev/null +++ b/homeassistant/components/incomfort/climate.py @@ -0,0 +1,93 @@ +"""Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway.""" +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import SUPPORT_TARGET_TEMPERATURE +from homeassistant.const import (ATTR_TEMPERATURE, TEMP_CELSIUS) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import DOMAIN + +INTOUCH_SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE + +INTOUCH_MAX_TEMP = 30.0 +INTOUCH_MIN_TEMP = 5.0 + + +async def async_setup_platform(hass, hass_config, async_add_entities, + discovery_info=None): + """Set up an InComfort/InTouch climate device.""" + 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) + + +class InComfortClimate(ClimateDevice): + """Representation of an InComfort/InTouch climate device.""" + + def __init__(self, client, room): + """Initialize the climate device.""" + self._client = client + self._room = room + self._name = 'Room {}'.format(room.room_no) + + 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 climate device.""" + return self._name + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return {'status': self._room.status} + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._room.room_temp + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._room.override + + @property + def min_temp(self): + """Return max valid temperature that can be set.""" + return INTOUCH_MIN_TEMP + + @property + def max_temp(self): + """Return max valid temperature that can be set.""" + return INTOUCH_MAX_TEMP + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def supported_features(self): + """Return the list of supported features.""" + return INTOUCH_SUPPORT_FLAGS + + async def async_set_temperature(self, **kwargs): + """Set a new target temperature for this zone.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + await self._room.set_override(temperature) + + @property + def should_poll(self) -> bool: + """Return False as this device should never be polled.""" + return False diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index 028a741a673..1731c8c942f 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.8" + "incomfort-client==0.2.9" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 7ba27cbe625..a8c5b553943 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -3,7 +3,7 @@ "name": "Insteon", "documentation": "https://www.home-assistant.io/components/insteon", "requirements": [ - "insteonplm==0.15.2" + "insteonplm==0.15.4" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index a9395ed5f5d..3fc09781cd7 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -9,8 +9,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.const import HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_entry_flow, config_validation as cv, discovery) +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.util.json import load_json, save_json _LOGGER = logging.getLogger(__name__) @@ -279,8 +278,3 @@ class iOSIdentifyDeviceView(HomeAssistantView): HTTP_INTERNAL_SERVER_ERROR) return self.json({"status": "registered"}) - - -config_entry_flow.register_discovery_flow( - DOMAIN, 'Home Assistant iOS', lambda *_: True, - config_entries.CONN_CLASS_CLOUD_PUSH) diff --git a/homeassistant/components/ios/config_flow.py b/homeassistant/components/ios/config_flow.py new file mode 100644 index 00000000000..c85d5066128 --- /dev/null +++ b/homeassistant/components/ios/config_flow.py @@ -0,0 +1,9 @@ +"""Config flow for iOS.""" +from homeassistant.helpers import config_entry_flow +from homeassistant import config_entries +from .const import DOMAIN + + +config_entry_flow.register_discovery_flow( + DOMAIN, 'Home Assistant iOS', lambda *_: True, + config_entries.CONN_CLASS_CLOUD_PUSH) diff --git a/homeassistant/components/ios/const.py b/homeassistant/components/ios/const.py new file mode 100644 index 00000000000..5fc921b7a44 --- /dev/null +++ b/homeassistant/components/ios/const.py @@ -0,0 +1,3 @@ +"""Const for iOS.""" + +DOMAIN = "ios" diff --git a/homeassistant/components/ios/manifest.json b/homeassistant/components/ios/manifest.json index 97c2e2ae28f..28c9ea1e952 100644 --- a/homeassistant/components/ios/manifest.json +++ b/homeassistant/components/ios/manifest.json @@ -1,6 +1,7 @@ { "domain": "ios", "name": "Ios", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/ios", "requirements": [], "dependencies": [ diff --git a/homeassistant/components/ipma/.translations/fr.json b/homeassistant/components/ipma/.translations/fr.json index 1ca5353ec7e..64d03c6ae71 100644 --- a/homeassistant/components/ipma/.translations/fr.json +++ b/homeassistant/components/ipma/.translations/fr.json @@ -10,6 +10,7 @@ "longitude": "Longitude", "name": "Nom" }, + "description": "Instituto Portugu\u00eas do Mar e Atmosfera", "title": "Emplacement" } }, diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json index 29fc0429e86..093ccbf6a5b 100644 --- a/homeassistant/components/ipma/manifest.json +++ b/homeassistant/components/ipma/manifest.json @@ -1,6 +1,7 @@ { "domain": "ipma", "name": "Ipma", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/ipma", "requirements": [ "pyipma==1.2.1" diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index e976bcb9896..a5c1d3e26f5 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -82,7 +82,7 @@ async def async_get_station(hass, latitude, longitude): from pyipma import Station websession = async_get_clientsession(hass) - with async_timeout.timeout(10, loop=hass.loop): + with async_timeout.timeout(10): station = await Station.get(websession, float(latitude), float(longitude)) @@ -106,7 +106,7 @@ class IPMAWeather(WeatherEntity): @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self): """Update Condition and Forecast.""" - with async_timeout.timeout(10, loop=self.hass.loop): + with async_timeout.timeout(10): _new_condition = await self._station.observation() if _new_condition is None: _LOGGER.warning("Could not update weather conditions") diff --git a/homeassistant/components/iqvia/.translations/ca.json b/homeassistant/components/iqvia/.translations/ca.json new file mode 100644 index 00000000000..249fd6d0ae2 --- /dev/null +++ b/homeassistant/components/iqvia/.translations/ca.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "Codi postal ja registrat", + "invalid_zip_code": "Codi postal incorrecte" + }, + "step": { + "user": { + "data": { + "zip_code": "Codi postal" + }, + "description": "Introdueix el teu codi postal d'Estats Units o Canad\u00e0.", + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/de.json b/homeassistant/components/iqvia/.translations/de.json new file mode 100644 index 00000000000..3a66a1e11a0 --- /dev/null +++ b/homeassistant/components/iqvia/.translations/de.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "Postleitzahl bereits registriert", + "invalid_zip_code": "Postleitzahl ist ung\u00fcltig" + }, + "step": { + "user": { + "data": { + "zip_code": "Postleitzahl" + }, + "description": "Trage eine US-amerikanische oder kanadische Postleitzahl ein.", + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/en.json b/homeassistant/components/iqvia/.translations/en.json new file mode 100644 index 00000000000..c3cc412d792 --- /dev/null +++ b/homeassistant/components/iqvia/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "ZIP code already registered", + "invalid_zip_code": "ZIP code is invalid" + }, + "step": { + "user": { + "data": { + "zip_code": "ZIP Code" + }, + "description": "Fill out your U.S. or Canadian ZIP code.", + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/es.json b/homeassistant/components/iqvia/.translations/es.json new file mode 100644 index 00000000000..91e34e82903 --- /dev/null +++ b/homeassistant/components/iqvia/.translations/es.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "C\u00f3digo postal ya registrado", + "invalid_zip_code": "El c\u00f3digo postal no es v\u00e1lido" + }, + "step": { + "user": { + "data": { + "zip_code": "C\u00f3digo postal" + }, + "description": "Indica tu c\u00f3digo postal de Estados Unidos o Canad\u00e1.", + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/fr.json b/homeassistant/components/iqvia/.translations/fr.json new file mode 100644 index 00000000000..f5e5907f2c4 --- /dev/null +++ b/homeassistant/components/iqvia/.translations/fr.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "Code postal d\u00e9j\u00e0 enregistr\u00e9", + "invalid_zip_code": "Code postal invalide" + }, + "step": { + "user": { + "data": { + "zip_code": "Code postal" + }, + "description": "Entrez votre code postal am\u00e9ricain ou canadien.", + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/ko.json b/homeassistant/components/iqvia/.translations/ko.json new file mode 100644 index 00000000000..a163891c042 --- /dev/null +++ b/homeassistant/components/iqvia/.translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "\uc6b0\ud3b8\ubc88\ud638\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_zip_code": "\uc6b0\ud3b8\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "zip_code": "\uc6b0\ud3b8\ubc88\ud638" + }, + "description": "\ubbf8\uad6d \ub610\ub294 \uce90\ub098\ub2e4\uc758 \uc6b0\ud3b8\ubc88\ud638\ub97c \uae30\uc785\ud574\uc8fc\uc138\uc694.", + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/lb.json b/homeassistant/components/iqvia/.translations/lb.json new file mode 100644 index 00000000000..8dc7c3bc20e --- /dev/null +++ b/homeassistant/components/iqvia/.translations/lb.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "Postleitzuel ass scho registr\u00e9iert", + "invalid_zip_code": "Postleitzuel ass ong\u00eblteg" + }, + "step": { + "user": { + "data": { + "zip_code": "Postleitzuel" + }, + "description": "Gitt \u00e4r U.S. oder Kanadesch Postleitzuel un.", + "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 new file mode 100644 index 00000000000..dccb7348a01 --- /dev/null +++ b/homeassistant/components/iqvia/.translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Postcode reeds geregistreerd", + "invalid_zip_code": "Postcode is ongeldig" + }, + "step": { + "user": { + "data": { + "zip_code": "Postcode" + }, + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/no.json b/homeassistant/components/iqvia/.translations/no.json new file mode 100644 index 00000000000..f04caf5bc8b --- /dev/null +++ b/homeassistant/components/iqvia/.translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "Postnummer er allerede registrert", + "invalid_zip_code": "Postnummeret er ugyldig" + }, + "step": { + "user": { + "data": { + "zip_code": "Postnummer" + }, + "description": "Fyll ut ditt amerikanske eller kanadiske postnummer.", + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/pl.json b/homeassistant/components/iqvia/.translations/pl.json new file mode 100644 index 00000000000..7a6e9a8a915 --- /dev/null +++ b/homeassistant/components/iqvia/.translations/pl.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "Kod pocztowy ju\u017c zarejestrowany", + "invalid_zip_code": "Kod pocztowy jest nieprawid\u0142owy" + }, + "step": { + "user": { + "data": { + "zip_code": "Kod pocztowy" + }, + "description": "Wprowad\u017a sw\u00f3j ameryka\u0144ski lub kanadyjski kod pocztowy.", + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/ru.json b/homeassistant/components/iqvia/.translations/ru.json new file mode 100644 index 00000000000..06a5b7e69dd --- /dev/null +++ b/homeassistant/components/iqvia/.translations/ru.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "\u041f\u043e\u0447\u0442\u043e\u0432\u044b\u0439 \u0438\u043d\u0434\u0435\u043a\u0441 \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d", + "invalid_zip_code": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u043e\u0447\u0442\u043e\u0432\u044b\u0439 \u0438\u043d\u0434\u0435\u043a\u0441" + }, + "step": { + "user": { + "data": { + "zip_code": "\u041f\u043e\u0447\u0442\u043e\u0432\u044b\u0439 \u0438\u043d\u0434\u0435\u043a\u0441" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0441\u0432\u043e\u0439 \u043f\u043e\u0447\u0442\u043e\u0432\u044b\u0439 \u0438\u043d\u0434\u0435\u043a\u0441 (\u0434\u043b\u044f \u0421\u0428\u0410 \u0438\u043b\u0438 \u041a\u0430\u043d\u0430\u0434\u044b).", + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/sl.json b/homeassistant/components/iqvia/.translations/sl.json new file mode 100644 index 00000000000..fa04c00c7a2 --- /dev/null +++ b/homeassistant/components/iqvia/.translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "Po\u0161tna \u0161tevilka je \u017ee registrirana", + "invalid_zip_code": "Po\u0161tna \u0161tevilka ni veljavna" + }, + "step": { + "user": { + "data": { + "zip_code": "Po\u0161tna \u0161tevilka" + }, + "description": "Izpolnite svojo ameri\u0161ko ali kanadsko po\u0161tno \u0161tevilko.", + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/sv.json b/homeassistant/components/iqvia/.translations/sv.json new file mode 100644 index 00000000000..5bb4029dfcc --- /dev/null +++ b/homeassistant/components/iqvia/.translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "Postnummer redan registrerat", + "invalid_zip_code": "Ogiltigt postnummer" + }, + "step": { + "user": { + "data": { + "zip_code": "Postnummer" + }, + "description": "Fyll i ditt Amerikanska eller Kanadensiska postnummer", + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/zh-Hans.json b/homeassistant/components/iqvia/.translations/zh-Hans.json new file mode 100644 index 00000000000..91d7a26d6c6 --- /dev/null +++ b/homeassistant/components/iqvia/.translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "\u90ae\u653f\u7f16\u7801\u5df2\u88ab\u6ce8\u518c", + "invalid_zip_code": "\u90ae\u653f\u7f16\u7801\u65e0\u6548" + }, + "step": { + "user": { + "data": { + "zip_code": "\u90ae\u653f\u7f16\u7801" + }, + "description": "\u586b\u5199\u60a8\u7684\u7f8e\u56fd\u6216\u52a0\u62ff\u5927\u90ae\u653f\u7f16\u7801\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/zh-Hant.json b/homeassistant/components/iqvia/.translations/zh-Hant.json new file mode 100644 index 00000000000..a09db3b02c3 --- /dev/null +++ b/homeassistant/components/iqvia/.translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "\u90f5\u905e\u5340\u865f\u5df2\u8a3b\u518a", + "invalid_zip_code": "\u90f5\u905e\u5340\u865f\u7121\u6548" + }, + "step": { + "user": { + "data": { + "zip_code": "\u90f5\u905e\u5340\u865f" + }, + "description": "\u586b\u5beb\u7f8e\u570b\u6216\u52a0\u62ff\u5927\u90f5\u905e\u5340\u865f\u3002", + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index 23803d7f17d..c58a7508e81 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -8,28 +8,27 @@ from pyiqvia.errors import IQVIAError, InvalidZipError import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv -from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.decorator import Registry +from .config_flow import configured_instances from .const import ( - DATA_CLIENT, DATA_LISTENER, DOMAIN, SENSORS, TOPIC_DATA_UPDATE, - TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_INDEX, TYPE_ALLERGY_OUTLOOK, - TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW, TYPE_ASTHMA_FORECAST, - TYPE_ASTHMA_INDEX, TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW, - TYPE_DISEASE_FORECAST, TYPE_DISEASE_INDEX, TYPE_DISEASE_TODAY) + CONF_ZIP_CODE, DATA_CLIENT, DATA_LISTENER, DOMAIN, SENSORS, + TOPIC_DATA_UPDATE, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_INDEX, + TYPE_ALLERGY_OUTLOOK, TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW, + TYPE_ASTHMA_FORECAST, TYPE_ASTHMA_INDEX, TYPE_ASTHMA_TODAY, + TYPE_ASTHMA_TOMORROW, TYPE_DISEASE_FORECAST, TYPE_DISEASE_INDEX, + TYPE_DISEASE_TODAY) _LOGGER = logging.getLogger(__name__) - -CONF_ZIP_CODE = 'zip_code' - DATA_CONFIG = 'config' DEFAULT_ATTRIBUTION = 'Data provided by IQVIA™' @@ -59,23 +58,40 @@ async def async_setup(hass, config): hass.data[DOMAIN][DATA_CLIENT] = {} hass.data[DOMAIN][DATA_LISTENER] = {} + if DOMAIN not in config: + return True + conf = config[DOMAIN] + if conf[CONF_ZIP_CODE] in configured_instances(hass): + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={'source': SOURCE_IMPORT}, data=conf)) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up IQVIA as config entry.""" websession = aiohttp_client.async_get_clientsession(hass) try: iqvia = IQVIAData( - Client(conf[CONF_ZIP_CODE], websession), - conf[CONF_MONITORED_CONDITIONS]) + Client(config_entry.data[CONF_ZIP_CODE], websession), + config_entry.data.get(CONF_MONITORED_CONDITIONS, list(SENSORS))) await iqvia.async_update() - except IQVIAError as err: - _LOGGER.error('Unable to set up IQVIA: %s', err) + except InvalidZipError: + _LOGGER.error( + 'Invalid ZIP code provided: %s', config_entry.data[CONF_ZIP_CODE]) return False - hass.data[DOMAIN][DATA_CLIENT] = iqvia + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = iqvia hass.async_create_task( - async_load_platform(hass, 'sensor', DOMAIN, {}, config)) + hass.config_entries.async_forward_entry_setup( + config_entry, 'sensor')) async def refresh(event_time): """Refresh IQVIA data.""" @@ -83,8 +99,23 @@ async def async_setup(hass, config): await iqvia.async_update() async_dispatcher_send(hass, TOPIC_DATA_UPDATE) - hass.data[DOMAIN][DATA_LISTENER] = async_track_time_interval( - hass, refresh, DEFAULT_SCAN_INTERVAL) + hass.data[DOMAIN][DATA_LISTENER][ + config_entry.entry_id] = async_track_time_interval( + hass, refresh, DEFAULT_SCAN_INTERVAL) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an OpenUV config entry.""" + hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) + + remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop( + config_entry.entry_id) + remove_listener() + + await hass.config_entries.async_forward_entry_unload( + config_entry, 'sensor') return True @@ -127,16 +158,7 @@ class IQVIAData: results = await asyncio.gather(*tasks.values(), return_exceptions=True) - # IQVIA sites require a bit more complicated error handling, given that - # they sometimes have parts (but not the whole thing) go down: - # 1. If `InvalidZipError` is thrown, quit everything immediately. - # 2. If a single request throws any other error, try the others. for key, result in zip(tasks, results): - if isinstance(result, InvalidZipError): - _LOGGER.error("No data for ZIP: %s", self._client.zip_code) - self.data = {} - return - if isinstance(result, IQVIAError): _LOGGER.error('Unable to get %s data: %s', key, result) self.data[key] = {} diff --git a/homeassistant/components/iqvia/config_flow.py b/homeassistant/components/iqvia/config_flow.py new file mode 100644 index 00000000000..fadecc8f3a7 --- /dev/null +++ b/homeassistant/components/iqvia/config_flow.py @@ -0,0 +1,64 @@ +"""Config flow to configure the IQVIA component.""" + +from collections import OrderedDict +import voluptuous as vol + +from pyiqvia import Client +from pyiqvia.errors import InvalidZipError + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client + +from .const import CONF_ZIP_CODE, DOMAIN + + +@callback +def configured_instances(hass): + """Return a set of configured IQVIA instances.""" + return set( + entry.data[CONF_ZIP_CODE] + for entry in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class IQVIAFlowHandler(config_entries.ConfigFlow): + """Handle an IQVIA config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize the config flow.""" + self.data_schema = OrderedDict() + self.data_schema[vol.Required(CONF_ZIP_CODE)] = str + + async def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id='user', + data_schema=vol.Schema(self.data_schema), + errors=errors if errors else {}, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + if not user_input: + return await self._show_form() + + if user_input[CONF_ZIP_CODE] in configured_instances(self.hass): + return await self._show_form({CONF_ZIP_CODE: 'identifier_exists'}) + + websession = aiohttp_client.async_get_clientsession(self.hass) + + try: + Client(user_input[CONF_ZIP_CODE], websession) + except InvalidZipError: + return await self._show_form({CONF_ZIP_CODE: 'invalid_zip_code'}) + + return self.async_create_entry( + title=user_input[CONF_ZIP_CODE], data=user_input) diff --git a/homeassistant/components/iqvia/const.py b/homeassistant/components/iqvia/const.py index 025fa8a9505..e9bffabcc43 100644 --- a/homeassistant/components/iqvia/const.py +++ b/homeassistant/components/iqvia/const.py @@ -1,6 +1,8 @@ """Define IQVIA constants.""" DOMAIN = 'iqvia' +CONF_ZIP_CODE = 'zip_code' + DATA_CLIENT = 'client' DATA_LISTENER = 'listener' diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 1757ffc2a22..381165847ef 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -1,10 +1,11 @@ { "domain": "iqvia", "name": "IQVIA", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/iqvia", "requirements": [ "numpy==1.16.3", - "pyiqvia==0.2.0" + "pyiqvia==0.2.1" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index b0b09c3f977..b7f7519b543 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -54,8 +54,13 @@ TREND_SUBSIDING = 'Subsiding' async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Configure the platform and add the sensors.""" - iqvia = hass.data[DOMAIN][DATA_CLIENT] + """Set up IQVIA sensors based on the old way.""" + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up IQVIA sensors based on a config entry.""" + iqvia = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] sensor_class_mapping = { TYPE_ALLERGY_FORECAST: ForecastSensor, @@ -102,7 +107,7 @@ class ForecastSensor(IQVIAEntity): return data = self._iqvia.data[self._type].get('Location') - if not data: + if not data or not data.get('periods'): return indices = [p['Index'] for p in data['periods']] diff --git a/homeassistant/components/iqvia/strings.json b/homeassistant/components/iqvia/strings.json new file mode 100644 index 00000000000..00f383be502 --- /dev/null +++ b/homeassistant/components/iqvia/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "IQVIA", + "step": { + "user": { + "title": "IQVIA", + "description": "Fill out your U.S. or Canadian ZIP code.", + "data": { + "zip_code": "ZIP Code" + } + } + }, + "error": { + "identifier_exists": "ZIP code already registered", + "invalid_zip_code": "ZIP code is invalid" + } + } +} diff --git a/homeassistant/components/keenetic_ndms2/manifest.json b/homeassistant/components/keenetic_ndms2/manifest.json index d95e6384606..91c0c69a4fa 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.6" + "ndms2_client==0.0.7" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 96fb02a08a0..661ebd86187 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -231,7 +231,7 @@ async def async_setup_platform(hass, config, async_add_entities, update_tasks.append(update_coro) if update_tasks: - await asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks) if hass.services.has_service(DOMAIN, SERVICE_ADD_MEDIA): return diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 7e7fb1430cc..4a421274a18 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -2,69 +2,31 @@ import logging import pypck -from pypck.connection import PchkConnectionManager import voluptuous as vol +from homeassistant.components.climate import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP from homeassistant.const import ( CONF_ADDRESS, CONF_BINARY_SENSORS, CONF_COVERS, CONF_HOST, CONF_LIGHTS, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_SENSORS, CONF_SWITCHES, - CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME) + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.entity import Entity from .const import ( - BINSENSOR_PORTS, CONF_CONNECTIONS, CONF_DIM_MODE, CONF_DIMMABLE, - CONF_MOTOR, CONF_OUTPUT, CONF_SK_NUM_TRIES, CONF_SOURCE, CONF_TRANSITION, - DATA_LCN, DEFAULT_NAME, DIM_MODES, DOMAIN, KEYS, LED_PORTS, LOGICOP_PORTS, - MOTOR_PORTS, OUTPUT_PORTS, PATTERN_ADDRESS, RELAY_PORTS, S0_INPUTS, + 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) +from .helpers import has_unique_connection_names, is_address +from .services import ( + DynText, Led, LockKeys, LockRegulator, OutputAbs, OutputRel, OutputToggle, + Pck, Relays, SendKeys, VarAbs, VarRel, VarReset) _LOGGER = logging.getLogger(__name__) - -def has_unique_connection_names(connections): - """Validate that all connection names are unique. - - Use 'pchk' as default connection_name (or add a numeric suffix if - pchk' is already in use. - """ - for suffix, connection in enumerate(connections): - connection_name = connection.get(CONF_NAME) - if connection_name is None: - if suffix == 0: - connection[CONF_NAME] = DEFAULT_NAME - else: - connection[CONF_NAME] = '{}{:d}'.format(DEFAULT_NAME, suffix) - - schema = vol.Schema(vol.Unique()) - schema([connection.get(CONF_NAME) for connection in connections]) - return connections - - -def is_address(value): - """Validate the given address string. - - Examples for S000M005 at myhome: - myhome.s000.m005 - myhome.s0.m5 - myhome.0.5 ("m" is implicit if missing) - - Examples for s000g011 - myhome.0.g11 - myhome.s0.g11 - """ - matcher = PATTERN_ADDRESS.match(value) - if matcher: - is_group = (matcher.group('type') == 'g') - addr = (int(matcher.group('seg_id')), - int(matcher.group('id')), - is_group) - conn_id = matcher.group('conn_id') - return addr, conn_id - raise vol.error.Invalid('Not a valid address string.') - - BINARY_SENSORS_SCHEMA = vol.Schema({ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ADDRESS): is_address, @@ -72,6 +34,19 @@ BINARY_SENSORS_SCHEMA = vol.Schema({ BINSENSOR_PORTS)) }) +CLIMATES_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_ADDRESS): is_address, + vol.Required(CONF_SOURCE): vol.All(vol.Upper, vol.In(VARIABLES)), + vol.Required(CONF_SETPOINT): vol.All(vol.Upper, + vol.In(VARIABLES + SETPOINTS)), + vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float), + vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float), + vol.Optional(CONF_LOCKABLE, default=False): vol.Coerce(bool), + vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=TEMP_CELSIUS): + vol.In(TEMP_CELSIUS, TEMP_FAHRENHEIT) +}) + COVERS_SCHEMA = vol.Schema({ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ADDRESS): is_address, @@ -124,6 +99,8 @@ CONFIG_SCHEMA = vol.Schema({ cv.ensure_list, has_unique_connection_names, [CONNECTION_SCHEMA]), vol.Optional(CONF_BINARY_SENSORS): vol.All( cv.ensure_list, [BINARY_SENSORS_SCHEMA]), + vol.Optional(CONF_CLIMATES): vol.All( + cv.ensure_list, [CLIMATES_SCHEMA]), vol.Optional(CONF_COVERS): vol.All( cv.ensure_list, [COVERS_SCHEMA]), vol.Optional(CONF_LIGHTS): vol.All( @@ -136,19 +113,6 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -def get_connection(connections, connection_id=None): - """Return the connection object from list.""" - if connection_id is None: - connection = connections[0] - else: - for connection in connections: - if connection.connection_id == connection_id: - break - else: - raise ValueError('Unknown connection_id.') - return connection - - async def async_setup(hass, config): """Set up the LCN component.""" hass.data[DATA_LCN] = {} @@ -162,13 +126,14 @@ async def async_setup(hass, config): 'DIM_MODE': pypck.lcn_defs.OutputPortDimMode[ conf_connection[CONF_DIM_MODE]]} - connection = PchkConnectionManager(hass.loop, - conf_connection[CONF_HOST], - conf_connection[CONF_PORT], - conf_connection[CONF_USERNAME], - conf_connection[CONF_PASSWORD], - settings=settings, - connection_id=connection_name) + connection = pypck.connection.PchkConnectionManager( + hass.loop, + conf_connection[CONF_HOST], + conf_connection[CONF_PORT], + conf_connection[CONF_USERNAME], + conf_connection[CONF_PASSWORD], + settings=settings, + connection_id=connection_name) try: # establish connection to PCHK server @@ -184,6 +149,7 @@ async def async_setup(hass, config): # load platforms for component, conf_key in (('binary_sensor', CONF_BINARY_SENSORS), + ('climate', CONF_CLIMATES), ('cover', CONF_COVERS), ('light', CONF_LIGHTS), ('sensor', CONF_SENSORS), @@ -192,6 +158,24 @@ async def async_setup(hass, config): hass.async_create_task( async_load_platform(hass, component, DOMAIN, config[DOMAIN][conf_key], config)) + + # register service calls + for service_name, service in (('output_abs', OutputAbs), + ('output_rel', OutputRel), + ('output_toggle', OutputToggle), + ('relays', Relays), + ('var_abs', VarAbs), + ('var_reset', VarReset), + ('var_rel', VarRel), + ('lock_regulator', LockRegulator), + ('led', Led), + ('send_keys', SendKeys), + ('lock_keys', LockKeys), + ('dyn_text', DynText), + ('pck', Pck)): + hass.services.async_register(DOMAIN, service_name, + service(hass), service.schema) + return True @@ -200,7 +184,6 @@ class LcnDevice(Entity): def __init__(self, config, address_connection): """Initialize the LCN device.""" - self.pypck = pypck self.config = config self.address_connection = address_connection self._name = config[CONF_NAME] diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index a59494023bb..7f034b3e1ed 100755 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -4,9 +4,10 @@ import pypck from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.const import CONF_ADDRESS -from . import LcnDevice, get_connection +from . import LcnDevice from .const import ( BINSENSOR_PORTS, CONF_CONNECTIONS, CONF_SOURCE, DATA_LCN, SETPOINTS) +from .helpers import get_connection async def async_setup_platform(hass, hass_config, async_add_entities, @@ -43,7 +44,7 @@ class LcnRegulatorLockSensor(LcnDevice, BinarySensorDevice): super().__init__(config, address_connection) self.setpoint_variable = \ - self.pypck.lcn_defs.Var[config[CONF_SOURCE]] + pypck.lcn_defs.Var[config[CONF_SOURCE]] self._value = None @@ -60,7 +61,7 @@ class LcnRegulatorLockSensor(LcnDevice, BinarySensorDevice): def input_received(self, input_obj): """Set sensor value when LCN input object (command) is received.""" - if not isinstance(input_obj, self.pypck.inputs.ModStatusVar) or \ + if not isinstance(input_obj, pypck.inputs.ModStatusVar) or \ input_obj.get_var() != self.setpoint_variable: return @@ -76,7 +77,7 @@ class LcnBinarySensor(LcnDevice, BinarySensorDevice): super().__init__(config, address_connection) self.bin_sensor_port = \ - self.pypck.lcn_defs.BinSensorPort[config[CONF_SOURCE]] + pypck.lcn_defs.BinSensorPort[config[CONF_SOURCE]] self._value = None @@ -93,7 +94,7 @@ class LcnBinarySensor(LcnDevice, BinarySensorDevice): def input_received(self, input_obj): """Set sensor value when LCN input object (command) is received.""" - if not isinstance(input_obj, self.pypck.inputs.ModStatusBinSensors): + if not isinstance(input_obj, pypck.inputs.ModStatusBinSensors): return self._value = input_obj.get_state(self.bin_sensor_port.value) @@ -107,7 +108,7 @@ class LcnLockKeysSensor(LcnDevice, BinarySensorDevice): """Initialize the LCN sensor.""" super().__init__(config, address_connection) - self.source = self.pypck.lcn_defs.Key[config[CONF_SOURCE]] + self.source = pypck.lcn_defs.Key[config[CONF_SOURCE]] self._value = None async def async_added_to_hass(self): @@ -123,8 +124,8 @@ class LcnLockKeysSensor(LcnDevice, BinarySensorDevice): def input_received(self, input_obj): """Set sensor value when LCN input object (command) is received.""" - if not isinstance(input_obj, self.pypck.inputs.ModStatusKeyLocks) or \ - self.source not in self.pypck.lcn_defs.Key: + if not isinstance(input_obj, pypck.inputs.ModStatusKeyLocks) or \ + self.source not in pypck.lcn_defs.Key: return table_id = ord(self.source.name[0]) - 65 diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py new file mode 100644 index 00000000000..67ba6d90c53 --- /dev/null +++ b/homeassistant/components/lcn/climate.py @@ -0,0 +1,139 @@ +"""Support for LCN climate control.""" +import pypck + +from homeassistant.components.climate import ClimateDevice, const +from homeassistant.const import ( + ATTR_TEMPERATURE, CONF_ADDRESS, CONF_UNIT_OF_MEASUREMENT) + +from . import LcnDevice +from .const import ( + CONF_CONNECTIONS, CONF_LOCKABLE, CONF_MAX_TEMP, CONF_MIN_TEMP, + CONF_SETPOINT, CONF_SOURCE, DATA_LCN) +from .helpers import get_connection + + +async def async_setup_platform(hass, hass_config, async_add_entities, + discovery_info=None): + """Set up the LCN climate 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(LcnClimate(config, address_connection)) + + async_add_entities(devices) + + +class LcnClimate(LcnDevice, ClimateDevice): + """Representation of a LCN climate device.""" + + def __init__(self, config, address_connection): + """Initialize of a LCN climate device.""" + super().__init__(config, address_connection) + + self.variable = pypck.lcn_defs.Var[config[CONF_SOURCE]] + self.setpoint = pypck.lcn_defs.Var[config[CONF_SETPOINT]] + self.unit = pypck.lcn_defs.VarUnit.parse( + config[CONF_UNIT_OF_MEASUREMENT]) + + self.regulator_id = \ + pypck.lcn_defs.Var.to_set_point_id(self.setpoint) + self.is_lockable = config[CONF_LOCKABLE] + self._max_temp = config[CONF_MAX_TEMP] + self._min_temp = config[CONF_MIN_TEMP] + + self._current_temperature = None + self._target_temperature = None + self._is_on = True + + self.support = const.SUPPORT_TARGET_TEMPERATURE + if self.is_lockable: + self.support |= const.SUPPORT_ON_OFF + + async def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + await self.address_connection.activate_status_request_handler( + self.variable) + await self.address_connection.activate_status_request_handler( + self.setpoint) + + @property + def supported_features(self): + """Return the list of supported features.""" + return self.support + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self.unit.value + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + @property + def is_on(self): + """Return true if the device is on.""" + return self._is_on + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._max_temp + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._min_temp + + async def async_turn_on(self): + """Turn on.""" + self._is_on = True + self.address_connection.lock_regulator(self.regulator_id, False) + await self.async_update_ha_state() + + async def async_turn_off(self): + """Turn off.""" + self._is_on = False + self.address_connection.lock_regulator(self.regulator_id, True) + self._target_temperature = None + await self.async_update_ha_state() + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + + self._target_temperature = temperature + self.address_connection.var_abs( + self.setpoint, self._target_temperature, self.unit) + await self.async_update_ha_state() + + def input_received(self, input_obj): + """Set temperature value when LCN input object is received.""" + if not isinstance(input_obj, pypck.inputs.ModStatusVar): + 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.async_schedule_update_ha_state() diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index b745d0636c2..9307fb4d706 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -1,7 +1,6 @@ # coding: utf-8 """Constants for the LCN component.""" from itertools import product -import re from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT @@ -9,10 +8,6 @@ DOMAIN = 'lcn' DATA_LCN = 'lcn' DEFAULT_NAME = 'pchk' -# Regex for address validation -PATTERN_ADDRESS = re.compile('^((?P\\w+)\\.)?s?(?P\\d+)' - '\\.(?Pm|g)?(?P\\d+)$') - CONF_CONNECTIONS = 'connections' CONF_SK_NUM_TRIES = 'sk_num_tries' CONF_OUTPUT = 'output' @@ -20,7 +15,23 @@ CONF_DIM_MODE = 'dim_mode' CONF_DIMMABLE = 'dimmable' CONF_TRANSITION = 'transition' CONF_MOTOR = 'motor' +CONF_LOCKABLE = 'lockable' +CONF_VARIABLE = 'variable' +CONF_VALUE = 'value' +CONF_RELVARREF = 'value_reference' CONF_SOURCE = 'source' +CONF_SETPOINT = 'setpoint' +CONF_LED = 'led' +CONF_KEYS = 'keys' +CONF_TIME = 'time' +CONF_TIME_UNIT = 'time_unit' +CONF_TABLE = 'table' +CONF_ROW = 'row' +CONF_TEXT = 'text' +CONF_PCK = 'pck' +CONF_CLIMATES = 'climates' +CONF_MAX_TEMP = 'max_temp' +CONF_MIN_TEMP = 'min_temp' DIM_MODES = ['STEPS50', 'STEPS200'] @@ -36,6 +47,8 @@ MOTOR_PORTS = ['MOTOR1', 'MOTOR2', 'MOTOR3', 'MOTOR4'] LED_PORTS = ['LED1', 'LED2', 'LED3', 'LED4', 'LED5', 'LED6', 'LED7', 'LED8', 'LED9', 'LED10', 'LED11', 'LED12'] +LED_STATUS = ['OFF', 'ON', 'BLINK', 'FLICKER'] + LOGICOP_PORTS = ['LOGICOP1', 'LOGICOP2', 'LOGICOP3', 'LOGICOP4'] BINSENSOR_PORTS = ['BINSENSOR1', 'BINSENSOR2', 'BINSENSOR3', 'BINSENSOR4', @@ -70,3 +83,12 @@ VAR_UNITS = ['', 'LCN', 'NATIVE', 'VOLT', 'V', 'AMPERE', 'AMP', 'A', 'DEGREE', '°'] + +RELVARREF = ['CURRENT', 'PROG'] + +SENDKEYCOMMANDS = ['HIT', 'MAKE', 'BREAK', 'DONTSEND'] + +TIME_UNITS = ['SECONDS', 'SECOND', 'SEC', 'S', + 'MINUTES', 'MINUTE', 'MIN', 'M', + 'HOURS', 'HOUR', 'H', + 'DAYS', 'DAY', 'D'] diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index d07fa09c189..8b268aa617e 100755 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -4,8 +4,9 @@ import pypck from homeassistant.components.cover import CoverDevice from homeassistant.const import CONF_ADDRESS -from . import LcnDevice, get_connection +from . import LcnDevice from .const import CONF_CONNECTIONS, CONF_MOTOR, DATA_LCN +from .helpers import get_connection async def async_setup_platform(hass, hass_config, async_add_entities, @@ -34,7 +35,7 @@ class LcnCover(LcnDevice, CoverDevice): """Initialize the LCN cover.""" super().__init__(config, address_connection) - self.motor = self.pypck.lcn_defs.MotorPort[config[CONF_MOTOR]] + self.motor = pypck.lcn_defs.MotorPort[config[CONF_MOTOR]] self.motor_port_onoff = self.motor.value * 2 self.motor_port_updown = self.motor_port_onoff + 1 @@ -54,30 +55,30 @@ class LcnCover(LcnDevice, CoverDevice): async def async_close_cover(self, **kwargs): """Close the cover.""" self._closed = True - states = [self.pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 - states[self.motor.value] = self.pypck.lcn_defs.MotorStateModifier.DOWN + states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 + states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.DOWN self.address_connection.control_motors(states) await self.async_update_ha_state() async def async_open_cover(self, **kwargs): """Open the cover.""" self._closed = False - states = [self.pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 - states[self.motor.value] = self.pypck.lcn_defs.MotorStateModifier.UP + states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 + states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.UP self.address_connection.control_motors(states) await self.async_update_ha_state() async def async_stop_cover(self, **kwargs): """Stop the cover.""" self._closed = None - states = [self.pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 - states[self.motor.value] = self.pypck.lcn_defs.MotorStateModifier.STOP + states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 + states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.STOP self.address_connection.control_motors(states) await self.async_update_ha_state() def input_received(self, input_obj): """Set cover states when LCN input object (command) is received.""" - if not isinstance(input_obj, self.pypck.inputs.ModStatusRelays): + if not isinstance(input_obj, pypck.inputs.ModStatusRelays): return states = input_obj.states # list of boolean values (relay on/off) diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py new file mode 100644 index 00000000000..d663a6320b1 --- /dev/null +++ b/homeassistant/components/lcn/helpers.py @@ -0,0 +1,107 @@ +"""Helpers for LCN component.""" +import re + +import voluptuous as vol + +from homeassistant.const import CONF_NAME + +from .const import DEFAULT_NAME + +# Regex for address validation +PATTERN_ADDRESS = re.compile('^((?P\\w+)\\.)?s?(?P\\d+)' + '\\.(?Pm|g)?(?P\\d+)$') + + +def get_connection(connections, connection_id=None): + """Return the connection object from list.""" + if connection_id is None: + connection = connections[0] + else: + for connection in connections: + if connection.connection_id == connection_id: + break + else: + raise ValueError('Unknown connection_id.') + return connection + + +def has_unique_connection_names(connections): + """Validate that all connection names are unique. + + Use 'pchk' as default connection_name (or add a numeric suffix if + pchk' is already in use. + """ + for suffix, connection in enumerate(connections): + connection_name = connection.get(CONF_NAME) + if connection_name is None: + if suffix == 0: + connection[CONF_NAME] = DEFAULT_NAME + else: + connection[CONF_NAME] = '{}{:d}'.format(DEFAULT_NAME, suffix) + + schema = vol.Schema(vol.Unique()) + schema([connection.get(CONF_NAME) for connection in connections]) + return connections + + +def is_address(value): + """Validate the given address string. + + Examples for S000M005 at myhome: + myhome.s000.m005 + myhome.s0.m5 + myhome.0.5 ("m" is implicit if missing) + + Examples for s000g011 + myhome.0.g11 + myhome.s0.g11 + """ + matcher = PATTERN_ADDRESS.match(value) + if matcher: + is_group = (matcher.group('type') == 'g') + addr = (int(matcher.group('seg_id')), + int(matcher.group('id')), + is_group) + conn_id = matcher.group('conn_id') + return addr, conn_id + raise vol.error.Invalid('Not a valid address string.') + + +def is_relays_states_string(states_string): + """Validate the given states string and return states list.""" + if len(states_string) == 8: + states = [] + for state_string in states_string: + if state_string == '1': + state = 'ON' + elif state_string == '0': + state = 'OFF' + elif state_string == 'T': + state = 'TOGGLE' + elif state_string == '-': + state = 'NOCHANGE' + else: + raise vol.error.Invalid('Not a valid relay state string.') + states.append(state) + return states + raise vol.error.Invalid('Wrong length of relay state string.') + + +def is_key_lock_states_string(states_string): + """Validate the given states string and returns states list.""" + if len(states_string) == 8: + states = [] + for state_string in states_string: + if state_string == '1': + state = 'ON' + elif state_string == '0': + state = 'OFF' + elif state_string == 'T': + state = 'TOGGLE' + elif state_string == '-': + state = 'NOCHANGE' + else: + raise vol.error.Invalid('Not a valid key lock state string.') + states.append(state) + return states + raise vol.error.Invalid('Wrong length of key lock state string.') diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index 49cdff5de49..28d85d6d45a 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -6,10 +6,11 @@ from homeassistant.components.light import ( Light) from homeassistant.const import CONF_ADDRESS -from . import LcnDevice, get_connection +from . import LcnDevice from .const import ( CONF_CONNECTIONS, CONF_DIMMABLE, CONF_OUTPUT, CONF_TRANSITION, DATA_LCN, OUTPUT_PORTS) +from .helpers import get_connection async def async_setup_platform( @@ -43,9 +44,9 @@ class LcnOutputLight(LcnDevice, Light): """Initialize the LCN light.""" super().__init__(config, address_connection) - self.output = self.pypck.lcn_defs.OutputPort[config[CONF_OUTPUT]] + self.output = pypck.lcn_defs.OutputPort[config[CONF_OUTPUT]] - self._transition = self.pypck.lcn_defs.time_to_ramp_value( + self._transition = pypck.lcn_defs.time_to_ramp_value( config[CONF_TRANSITION]) self.dimmable = config[CONF_DIMMABLE] @@ -86,7 +87,7 @@ class LcnOutputLight(LcnDevice, Light): else: percent = 100 if ATTR_TRANSITION in kwargs: - transition = self.pypck.lcn_defs.time_to_ramp_value( + transition = pypck.lcn_defs.time_to_ramp_value( kwargs[ATTR_TRANSITION] * 1000) else: transition = self._transition @@ -99,7 +100,7 @@ class LcnOutputLight(LcnDevice, Light): """Turn the entity off.""" self._is_on = False if ATTR_TRANSITION in kwargs: - transition = self.pypck.lcn_defs.time_to_ramp_value( + transition = pypck.lcn_defs.time_to_ramp_value( kwargs[ATTR_TRANSITION] * 1000) else: transition = self._transition @@ -111,7 +112,7 @@ class LcnOutputLight(LcnDevice, Light): def input_received(self, input_obj): """Set light state when LCN input object (command) is received.""" - if not isinstance(input_obj, self.pypck.inputs.ModStatusOutput) or \ + if not isinstance(input_obj, pypck.inputs.ModStatusOutput) or \ input_obj.get_output_id() != self.output.value: return @@ -130,7 +131,7 @@ class LcnRelayLight(LcnDevice, Light): """Initialize the LCN light.""" super().__init__(config, address_connection) - self.output = self.pypck.lcn_defs.RelayPort[config[CONF_OUTPUT]] + self.output = pypck.lcn_defs.RelayPort[config[CONF_OUTPUT]] self._is_on = None @@ -149,8 +150,8 @@ class LcnRelayLight(LcnDevice, Light): """Turn the entity on.""" self._is_on = True - states = [self.pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 - states[self.output.value] = self.pypck.lcn_defs.RelayStateModifier.ON + states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 + states[self.output.value] = pypck.lcn_defs.RelayStateModifier.ON self.address_connection.control_relays(states) await self.async_update_ha_state() @@ -159,15 +160,15 @@ class LcnRelayLight(LcnDevice, Light): """Turn the entity off.""" self._is_on = False - states = [self.pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 - states[self.output.value] = self.pypck.lcn_defs.RelayStateModifier.OFF + states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 + states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF self.address_connection.control_relays(states) await self.async_update_ha_state() def input_received(self, input_obj): """Set light state when LCN input object (command) is received.""" - if not isinstance(input_obj, self.pypck.inputs.ModStatusRelays): + if not isinstance(input_obj, pypck.inputs.ModStatusRelays): return self._is_on = input_obj.get_state(self.output.value) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index bbf2746c067..5f0d1052741 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -3,8 +3,10 @@ "name": "Lcn", "documentation": "https://www.home-assistant.io/components/lcn", "requirements": [ - "pypck==0.5.9" + "pypck==0.6.0" ], "dependencies": [], - "codeowners": [] + "codeowners": [ + "@alengwenus" + ] } diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 38b17c80793..91d2b916cca 100755 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -3,10 +3,11 @@ import pypck from homeassistant.const import CONF_ADDRESS, CONF_UNIT_OF_MEASUREMENT -from . import LcnDevice, get_connection +from . import LcnDevice from .const import ( CONF_CONNECTIONS, CONF_SOURCE, DATA_LCN, LED_PORTS, S0_INPUTS, SETPOINTS, THRESHOLDS, VARIABLES) +from .helpers import get_connection async def async_setup_platform(hass, hass_config, async_add_entities, @@ -41,8 +42,8 @@ class LcnVariableSensor(LcnDevice): """Initialize the LCN sensor.""" super().__init__(config, address_connection) - self.variable = self.pypck.lcn_defs.Var[config[CONF_SOURCE]] - self.unit = self.pypck.lcn_defs.VarUnit.parse( + self.variable = pypck.lcn_defs.Var[config[CONF_SOURCE]] + self.unit = pypck.lcn_defs.VarUnit.parse( config[CONF_UNIT_OF_MEASUREMENT]) self._value = None @@ -65,7 +66,7 @@ class LcnVariableSensor(LcnDevice): def input_received(self, input_obj): """Set sensor value when LCN input object (command) is received.""" - if not isinstance(input_obj, self.pypck.inputs.ModStatusVar) or \ + if not isinstance(input_obj, pypck.inputs.ModStatusVar) or \ input_obj.get_var() != self.variable: return @@ -81,9 +82,9 @@ class LcnLedLogicSensor(LcnDevice): super().__init__(config, address_connection) if config[CONF_SOURCE] in LED_PORTS: - self.source = self.pypck.lcn_defs.LedPort[config[CONF_SOURCE]] + self.source = pypck.lcn_defs.LedPort[config[CONF_SOURCE]] else: - self.source = self.pypck.lcn_defs.LogicOpPort[config[CONF_SOURCE]] + self.source = pypck.lcn_defs.LogicOpPort[config[CONF_SOURCE]] self._value = None @@ -101,13 +102,13 @@ class LcnLedLogicSensor(LcnDevice): def input_received(self, input_obj): """Set sensor value when LCN input object (command) is received.""" if not isinstance(input_obj, - self.pypck.inputs.ModStatusLedsAndLogicOps): + pypck.inputs.ModStatusLedsAndLogicOps): return - if self.source in self.pypck.lcn_defs.LedPort: + if self.source in pypck.lcn_defs.LedPort: self._value = input_obj.get_led_state( self.source.value).name.lower() - elif self.source in self.pypck.lcn_defs.LogicOpPort: + elif self.source in pypck.lcn_defs.LogicOpPort: self._value = input_obj.get_logic_op_state( self.source.value).name.lower() diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py new file mode 100755 index 00000000000..78a887a80c1 --- /dev/null +++ b/homeassistant/components/lcn/services.py @@ -0,0 +1,326 @@ +"""Service calls related dependencies for LCN component.""" +import pypck +import voluptuous as vol + +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, + CONF_RELVARREF, CONF_ROW, CONF_SETPOINT, CONF_TABLE, CONF_TEXT, CONF_TIME, + CONF_TIME_UNIT, CONF_TRANSITION, CONF_VALUE, CONF_VARIABLE, DATA_LCN, + LED_PORTS, LED_STATUS, OUTPUT_PORTS, RELVARREF, SENDKEYCOMMANDS, SETPOINTS, + THRESHOLDS, TIME_UNITS, VAR_UNITS, VARIABLES) +from .helpers import ( + get_connection, is_address, is_key_lock_states_string, + is_relays_states_string) + + +class LcnServiceCall(): + """Parent class for all LCN service calls.""" + + schema = vol.Schema({ + vol.Required(CONF_ADDRESS): is_address + }) + + def __init__(self, hass): + """Initialize service call.""" + self.connections = hass.data[DATA_LCN][CONF_CONNECTIONS] + + def get_address_connection(self, call): + """Get address connection object.""" + addr, connection_id = call.data[CONF_ADDRESS] + addr = pypck.lcn_addr.LcnAddr(*addr) + if connection_id is None: + connection = self.connections[0] + else: + connection = get_connection(self.connections, connection_id) + + return connection.get_address_conn(addr) + + +class OutputAbs(LcnServiceCall): + """Set absolute brightness of output port in percent.""" + + schema = LcnServiceCall.schema.extend({ + vol.Required(CONF_OUTPUT): vol.All(vol.Upper, vol.In(OUTPUT_PORTS)), + vol.Required(CONF_BRIGHTNESS): + 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.""" + output = pypck.lcn_defs.OutputPort[call.data[CONF_OUTPUT]] + brightness = call.data[CONF_BRIGHTNESS] + transition = pypck.lcn_defs.time_to_ramp_value( + call.data[CONF_TRANSITION] * 1000) + + address_connection = self.get_address_connection(call) + address_connection.dim_output(output.value, brightness, transition) + + +class OutputRel(LcnServiceCall): + """Set relative brightness of output port in percent.""" + + schema = LcnServiceCall.schema.extend({ + 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.""" + output = pypck.lcn_defs.OutputPort[call.data[CONF_OUTPUT]] + brightness = call.data[CONF_BRIGHTNESS] + + address_connection = self.get_address_connection(call) + address_connection.rel_output(output.value, brightness) + + +class OutputToggle(LcnServiceCall): + """Toggle output port.""" + + schema = LcnServiceCall.schema.extend({ + 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.""" + output = pypck.lcn_defs.OutputPort[call.data[CONF_OUTPUT]] + transition = pypck.lcn_defs.time_to_ramp_value( + call.data[CONF_TRANSITION] * 1000) + + address_connection = self.get_address_connection(call) + address_connection.toggle_output(output.value, transition) + + +class Relays(LcnServiceCall): + """Set the relays status.""" + + schema = LcnServiceCall.schema.extend({ + vol.Required(CONF_STATE): is_relays_states_string}) + + def __call__(self, call): + """Execute service call.""" + states = [pypck.lcn_defs.RelayStateModifier[state] + for state in call.data[CONF_STATE]] + + address_connection = self.get_address_connection(call) + address_connection.control_relays(states) + + +class Led(LcnServiceCall): + """Set the led state.""" + + schema = LcnServiceCall.schema.extend({ + vol.Required(CONF_LED): vol.All(vol.Upper, vol.In(LED_PORTS)), + vol.Required(CONF_STATE): vol.All(vol.Upper, vol.In(LED_STATUS))}) + + def __call__(self, call): + """Execute service call.""" + led = pypck.lcn_defs.LedPort[call.data[CONF_LED]] + led_state = pypck.lcn_defs.LedStatus[ + call.data[CONF_STATE]] + + address_connection = self.get_address_connection(call) + address_connection.control_led(led, led_state) + + +class VarAbs(LcnServiceCall): + """Set absolute value of a variable or setpoint. + + Variable has to be set as counter! + Reguator setpoints can also be set using R1VARSETPOINT, R2VARSETPOINT. + """ + + schema = LcnServiceCall.schema.extend({ + vol.Required(CONF_VARIABLE): vol.All(vol.Upper, + vol.In(VARIABLES + SETPOINTS)), + vol.Optional(CONF_VALUE, default=0): + 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.""" + var = pypck.lcn_defs.Var[call.data[CONF_VARIABLE]] + value = call.data[CONF_VALUE] + unit = pypck.lcn_defs.VarUnit.parse( + call.data[CONF_UNIT_OF_MEASUREMENT]) + + address_connection = self.get_address_connection(call) + address_connection.var_abs(var, value, unit) + + +class VarReset(LcnServiceCall): + """Reset value of variable or setpoint.""" + + schema = LcnServiceCall.schema.extend({ + vol.Required(CONF_VARIABLE): vol.All(vol.Upper, + vol.In(VARIABLES + SETPOINTS)) + }) + + def __call__(self, call): + """Execute service call.""" + var = pypck.lcn_defs.Var[call.data[CONF_VARIABLE]] + + address_connection = self.get_address_connection(call) + address_connection.var_reset(var) + + +class VarRel(LcnServiceCall): + """Shift value of a variable, setpoint or threshold.""" + + schema = LcnServiceCall.schema.extend({ + vol.Required(CONF_VARIABLE): + vol.All(vol.Upper, vol.In(VARIABLES + SETPOINTS + THRESHOLDS)), + vol.Optional(CONF_VALUE, default=0): int, + vol.Optional(CONF_UNIT_OF_MEASUREMENT, default='native'): + 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.""" + var = pypck.lcn_defs.Var[call.data[CONF_VARIABLE]] + value = call.data[CONF_VALUE] + unit = pypck.lcn_defs.VarUnit.parse( + call.data[CONF_UNIT_OF_MEASUREMENT]) + value_ref = pypck.lcn_defs.RelVarRef[ + call.data[CONF_RELVARREF]] + + address_connection = self.get_address_connection(call) + address_connection.var_rel(var, value, unit, value_ref) + + +class LockRegulator(LcnServiceCall): + """Locks a regulator setpoint.""" + + 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.""" + setpoint = pypck.lcn_defs.Var[call.data[CONF_SETPOINT]] + state = call.data[CONF_STATE] + + reg_id = pypck.lcn_defs.Var.to_set_point_id(setpoint) + address_connection = self.get_address_connection(call) + address_connection.lock_regulator(reg_id, state) + + +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.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.""" + address_connection = self.get_address_connection(call) + + keys = [[False] * 8 for i in range(4)] + + key_strings = zip(call.data[CONF_KEYS][::2], + call.data[CONF_KEYS][1::2]) + + for table, key in key_strings: + table_id = ord(table) - 65 + key_id = int(key) - 1 + keys[table_id][key_id] = True + + delay_time = call.data[CONF_TIME] + if delay_time != 0: + hit = pypck.lcn_defs.SendKeyCommand.HIT + if pypck.lcn_defs.SendKeyCommand[ + call.data[CONF_STATE]] != hit: + raise ValueError('Only hit command is allowed when sending' + ' deferred keys.') + delay_unit = pypck.lcn_defs.TimeUnit.parse( + call.data[CONF_TIME_UNIT]) + address_connection.send_keys_hit_deferred( + keys, delay_time, delay_unit) + else: + state = pypck.lcn_defs.SendKeyCommand[ + call.data[CONF_STATE]] + address_connection.send_keys(keys, state) + + +class LockKeys(LcnServiceCall): + """Lock keys.""" + + schema = LcnServiceCall.schema.extend({ + vol.Optional(CONF_TABLE, default='a'): cv.matches_regex(r'^[a-dA-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.""" + address_connection = self.get_address_connection(call) + + states = [pypck.lcn_defs.KeyLockStateModifier[state] + for state in call.data[CONF_STATE]] + table_id = ord(call.data[CONF_TABLE]) - 65 + + delay_time = call.data[CONF_TIME] + if delay_time != 0: + if table_id != 0: + raise ValueError('Only table A is allowed when locking keys' + ' for a specific time.') + delay_unit = pypck.lcn_defs.TimeUnit.parse( + call.data[CONF_TIME_UNIT]) + address_connection.lock_keys_tab_a_temporary( + delay_time, delay_unit, states) + else: + address_connection.lock_keys(table_id, states) + + address_connection.request_status_locked_keys_timeout() + + +class DynText(LcnServiceCall): + """Send dynamic text to LCN-GTxD displays.""" + + 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.""" + row_id = call.data[CONF_ROW] - 1 + text = call.data[CONF_TEXT] + + address_connection = self.get_address_connection(call) + address_connection.dyn_text(row_id, text) + + +class Pck(LcnServiceCall): + """Send arbitrary PCK command.""" + + schema = LcnServiceCall.schema.extend({ + vol.Required(CONF_PCK): str + }) + + def __call__(self, call): + """Execute service call.""" + pck = call.data[CONF_PCK] + address_connection = self.get_address_connection(call) + address_connection.pck(pck) diff --git a/homeassistant/components/lcn/services.yaml b/homeassistant/components/lcn/services.yaml new file mode 100755 index 00000000000..b8f4fbb20a7 --- /dev/null +++ b/homeassistant/components/lcn/services.yaml @@ -0,0 +1,201 @@ +# Describes the format for available LCN services + +output_abs: + description: Set absolute brightness of output port in percent. + fields: + address: + description: Module address + example: 'myhome.s0.m7' + output: + description: Output port + example: "output1" + brightness: + description: Absolute brightness in percent (0..100) + example: 50 + transition: + description: Transition time in seconds + example: 5 + +output_rel: + description: Set relative brightness of output port in percent. + fields: + address: + description: Module address + example: 'myhome.s0.m7' + output: + description: Output port + example: "output1" + brightness: + description: Relative brightness in percent (-100..100) + example: 50 + transition: + description: Transition time in seconds + example: 5 + +output_toggle: + description: Toggle output port. + fields: + address: + description: Module address + example: 'myhome.s0.m7' + output: + description: Output port + example: "output1" + transition: + description: Transition time in seconds + example: 5 + +relays: + description: Set the relays status. + fields: + address: + description: Module address + example: 'myhome.s0.m7' + state: + description: Relays states as string (1=on, 2=off, t=toggle, -=nochange) + example: "t---001-" + +led: + description: Set the led state. + fields: + address: + description: Module address + example: 'myhome.s0.m7' + led: + description: Led + example: "led6" + state: + description: Led state + example: 'blink' + values: + - on + - off + - blink + - flicker + +var_abs: + description: Set absolute value of a variable or setpoint. + fields: + address: + description: Module address + example: 'myhome.s0.m7' + variable: + description: Variable or setpoint name + example: 'var1' + value: + description: Value to set + example: '50' + unit_of_measurement: + description: Unit of value + example: 'celsius' + +var_reset: + description: Reset value of variable or setpoint. + fields: + address: + description: Module address + example: 'myhome.s0.m7' + variable: + description: Variable or setpoint name + example: 'var1' + +var_rel: + description: Shift value of a variable, setpoint or threshold. + fields: + address: + description: Module address + example: 'myhome.s0.m7' + variable: + description: Variable or setpoint name + example: 'var1' + value: + description: Shift value + example: '50' + unit_of_measurement: + description: Unit of value + example: 'celsius' + value_reference: + description: Reference value (current or programmed) for setpoint and threshold + example: 'current' + values: + - current + - prog + +lock_regulator: + description: Lock a regulator setpoint. + fields: + address: + description: Module address + example: 'myhome.s0.m7' + setpoint: + description: Setpoint name + example: 'r1varsetpoint' + state: + description: New setpoint state + example: true + +send_keys: + description: Send keys (which executes bound commands). + fields: + address: + description: Module address + example: 'myhome.s0.m7' + keys: + description: Keys to send + example: 'a1a5d8' + state: + description: 'Key state upon sending (optional, must be hit for deferred)' + example: 'hit' + values: + - hit + - make + - break + time: + description: Send delay (optional) + example: 10 + time_unit: + description: Time unit of send delay (optional) + example: 's' + +lock_keys: + description: Lock keys. + fields: + address: + description: Module address + example: 'myhome.s0.m7' + table: + description: 'Table with keys to lock (optional, must be A for interval).' + example: 'A5' + state: + description: Key lock states as string (1=on, 2=off, T=toggle, -=nochange) + example: '1---t0--' + time: + description: Lock interval (optional) + example: 10 + time_unit: + description: Time unit of lock interval (optional) + example: 's' + +dyn_text: + description: Send dynamic text to LCN-GTxD displays. + fields: + address: + description: Module address + example: 'myhome.s0.m7' + row: + description: Text row 1..4 (support of 4 independent text rows) + example: 1 + text: + description: Text to send (up to 60 characters encoded as UTF-8) + example: 'text up to 60 characters' + +pck: + description: Send arbitrary PCK command. + fields: + address: + description: Module address + example: 'myhome.s0.m7' + pck: + description: PCK command (without address header) + example: 'PIN4' + \ No newline at end of file diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index e5a8484e271..1e86609c38c 100755 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -4,8 +4,9 @@ import pypck from homeassistant.components.switch import SwitchDevice from homeassistant.const import CONF_ADDRESS -from . import LcnDevice, get_connection +from . import LcnDevice from .const import CONF_CONNECTIONS, CONF_OUTPUT, DATA_LCN, OUTPUT_PORTS +from .helpers import get_connection async def async_setup_platform(hass, hass_config, async_add_entities, @@ -39,7 +40,7 @@ class LcnOutputSwitch(LcnDevice, SwitchDevice): """Initialize the LCN switch.""" super().__init__(config, address_connection) - self.output = self.pypck.lcn_defs.OutputPort[config[CONF_OUTPUT]] + self.output = pypck.lcn_defs.OutputPort[config[CONF_OUTPUT]] self._is_on = None @@ -68,7 +69,7 @@ class LcnOutputSwitch(LcnDevice, SwitchDevice): def input_received(self, input_obj): """Set switch state when LCN input object (command) is received.""" - if not isinstance(input_obj, self.pypck.inputs.ModStatusOutput) or \ + if not isinstance(input_obj, pypck.inputs.ModStatusOutput) or \ input_obj.get_output_id() != self.output.value: return @@ -83,7 +84,7 @@ class LcnRelaySwitch(LcnDevice, SwitchDevice): """Initialize the LCN switch.""" super().__init__(config, address_connection) - self.output = self.pypck.lcn_defs.RelayPort[config[CONF_OUTPUT]] + self.output = pypck.lcn_defs.RelayPort[config[CONF_OUTPUT]] self._is_on = None @@ -102,8 +103,8 @@ class LcnRelaySwitch(LcnDevice, SwitchDevice): """Turn the entity on.""" self._is_on = True - states = [self.pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 - states[self.output.value] = self.pypck.lcn_defs.RelayStateModifier.ON + states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 + states[self.output.value] = pypck.lcn_defs.RelayStateModifier.ON self.address_connection.control_relays(states) await self.async_update_ha_state() @@ -111,14 +112,14 @@ class LcnRelaySwitch(LcnDevice, SwitchDevice): """Turn the entity off.""" self._is_on = False - states = [self.pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 - states[self.output.value] = self.pypck.lcn_defs.RelayStateModifier.OFF + states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 + states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF self.address_connection.control_relays(states) await self.async_update_ha_state() def input_received(self, input_obj): """Set switch state when LCN input object (command) is received.""" - if not isinstance(input_obj, self.pypck.inputs.ModStatusRelays): + if not isinstance(input_obj, pypck.inputs.ModStatusRelays): return self._is_on = input_obj.get_state(self.output.value) diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 849fecad487..ceea489614a 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -4,10 +4,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant import config_entries from homeassistant.const import CONF_PORT -from homeassistant.helpers import config_entry_flow from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from .const import DOMAIN + -DOMAIN = 'lifx' CONF_SERVER = 'server' CONF_BROADCAST = 'broadcast' @@ -55,15 +55,3 @@ async def async_unload_entry(hass, entry): await hass.config_entries.async_forward_entry_unload(entry, LIGHT_DOMAIN) return True - - -async def _async_has_devices(hass): - """Return if there are devices that can be discovered.""" - import aiolifx - - lifx_ip_addresses = await aiolifx.LifxScan(hass.loop).scan() - return len(lifx_ip_addresses) > 0 - - -config_entry_flow.register_discovery_flow( - DOMAIN, 'LIFX', _async_has_devices, config_entries.CONN_CLASS_LOCAL_POLL) diff --git a/homeassistant/components/lifx/config_flow.py b/homeassistant/components/lifx/config_flow.py new file mode 100644 index 00000000000..b701c4e4391 --- /dev/null +++ b/homeassistant/components/lifx/config_flow.py @@ -0,0 +1,16 @@ +"""Config flow flow LIFX.""" +from homeassistant.helpers import config_entry_flow +from homeassistant import config_entries +from .const import DOMAIN + + +async def _async_has_devices(hass): + """Return if there are devices that can be discovered.""" + import aiolifx + + lifx_ip_addresses = await aiolifx.LifxScan(hass.loop).scan() + return len(lifx_ip_addresses) > 0 + + +config_entry_flow.register_discovery_flow( + DOMAIN, 'LIFX', _async_has_devices, config_entries.CONN_CLASS_LOCAL_POLL) diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py new file mode 100644 index 00000000000..fa54433e58f --- /dev/null +++ b/homeassistant/components/lifx/const.py @@ -0,0 +1,3 @@ +"""Const for LIFX.""" + +DOMAIN = 'lifx' diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 04f756e6ded..5f462941062 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -202,7 +202,7 @@ class LIFXManager: self.entities = {} self.hass = hass self.async_add_entities = async_add_entities - self.effects_conductor = aiolifx_effects().Conductor(loop=hass.loop) + self.effects_conductor = aiolifx_effects().Conductor(hass.loop) self.discoveries = [] self.cleanup_unsub = self.hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, @@ -253,7 +253,7 @@ class LIFXManager: task = light.set_state(**service.data) tasks.append(self.hass.async_create_task(task)) if tasks: - await asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks) self.hass.services.async_register( DOMAIN, SERVICE_LIFX_SET_STATE, service_handler, diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index a8b1fd58afe..fd74d9831fc 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -1,11 +1,17 @@ { "domain": "lifx", "name": "Lifx", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/lifx", "requirements": [ "aiolifx==0.6.7", "aiolifx_effects==0.2.2" ], + "homekit": { + "models": [ + "LIFX" + ] + }, "dependencies": [], "codeowners": [ "@amelchio" diff --git a/homeassistant/components/lifx_cloud/scene.py b/homeassistant/components/lifx_cloud/scene.py index c877bddbe53..fd6f548c0b1 100644 --- a/homeassistant/components/lifx_cloud/scene.py +++ b/homeassistant/components/lifx_cloud/scene.py @@ -38,7 +38,7 @@ async def async_setup_platform(hass, config, async_add_entities, try: httpsession = async_get_clientsession(hass) - with async_timeout.timeout(timeout, loop=hass.loop): + with async_timeout.timeout(timeout): scenes_resp = await httpsession.get(url, headers=headers) except (asyncio.TimeoutError, aiohttp.ClientError): @@ -83,7 +83,7 @@ class LifxCloudScene(Scene): try: httpsession = async_get_clientsession(self.hass) - with async_timeout.timeout(self._timeout, loop=self.hass.loop): + with async_timeout.timeout(self._timeout): await httpsession.put(url, headers=self._headers) except (asyncio.TimeoutError, aiohttp.ClientError): diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index f9ce6eb05d4..d5fc087888e 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -303,7 +303,7 @@ async def async_setup(hass, config): light.async_update_ha_state(True)) if update_tasks: - await asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks) # Listen for light on and light off service calls. hass.services.async_register( diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py index 50e6f69b0bd..49502186d8e 100644 --- a/homeassistant/components/locative/__init__.py +++ b/homeassistant/components/locative/__init__.py @@ -49,6 +49,10 @@ WEBHOOK_SCHEMA = vol.All( async def async_setup(hass, hass_config): """Set up the Locative component.""" + hass.data[DOMAIN] = { + 'devices': set(), + 'unsub_device_tracker': {}, + } return True @@ -139,18 +143,10 @@ async def async_setup_entry(hass, entry): async def async_unload_entry(hass, entry): """Unload a config entry.""" hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + hass.data[DOMAIN]['unsub_device_tracker'].pop(entry.entry_id)() await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER) return True # pylint: disable=invalid-name async_remove_entry = config_entry_flow.webhook_async_remove_entry - - -config_entry_flow.register_webhook_flow( - DOMAIN, - 'Locative Webhook', - { - 'docs_url': 'https://www.home-assistant.io/components/locative/' - } -) diff --git a/homeassistant/components/locative/config_flow.py b/homeassistant/components/locative/config_flow.py new file mode 100644 index 00000000000..4a238e95358 --- /dev/null +++ b/homeassistant/components/locative/config_flow.py @@ -0,0 +1,12 @@ +"""Config flow for Locative.""" +from homeassistant.helpers import config_entry_flow +from .const import DOMAIN + + +config_entry_flow.register_webhook_flow( + DOMAIN, + 'Locative Webhook', + { + 'docs_url': 'https://www.home-assistant.io/components/locative/' + } +) diff --git a/homeassistant/components/locative/const.py b/homeassistant/components/locative/const.py new file mode 100644 index 00000000000..4dfaa54de78 --- /dev/null +++ b/homeassistant/components/locative/const.py @@ -0,0 +1,3 @@ +"""Const for Locative.""" + +DOMAIN = "locative" diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py index 1e16bde58ad..6f86519c47c 100644 --- a/homeassistant/components/locative/device_tracker.py +++ b/homeassistant/components/locative/device_tracker.py @@ -1,35 +1,90 @@ """Support for the Locative platform.""" import logging -from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN) +from homeassistant.core import callback +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import ( + DeviceTrackerEntity +) from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.util import slugify -from . import DOMAIN as LOCATIVE_DOMAIN, TRACKER_UPDATE +from . import DOMAIN as LT_DOMAIN, TRACKER_UPDATE _LOGGER = logging.getLogger(__name__) -DATA_KEY = '{}.{}'.format(LOCATIVE_DOMAIN, DEVICE_TRACKER_DOMAIN) - -async def async_setup_entry(hass, entry, async_see): +async def async_setup_entry(hass, entry, async_add_entities): """Configure a dispatcher connection based on a config entry.""" - async def _set_location(device, gps_location, location_name): - """Fire HA event to set location.""" - await async_see( - dev_id=slugify(device), - gps=gps_location, - location_name=location_name - ) + @callback + def _receive_data(device, location, location_name): + """Receive set location.""" + if device in hass.data[LT_DOMAIN]['devices']: + return + + hass.data[LT_DOMAIN]['devices'].add(device) + + async_add_entities([LocativeEntity( + device, location, location_name + )]) + + hass.data[LT_DOMAIN]['unsub_device_tracker'][entry.entry_id] = \ + async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) - hass.data[DATA_KEY] = async_dispatcher_connect( - hass, TRACKER_UPDATE, _set_location - ) return True -async def async_unload_entry(hass, entry): - """Unload the config entry and remove the dispatcher connection.""" - hass.data[DATA_KEY]() - return True +class LocativeEntity(DeviceTrackerEntity): + """Represent a tracked device.""" + + def __init__(self, device, location, location_name): + """Set up Locative entity.""" + self._name = device + self._location = location + self._location_name = location_name + self._unsub_dispatcher = None + + @property + def latitude(self): + """Return latitude value of the device.""" + return self._location[0] + + @property + def longitude(self): + """Return longitude value of the device.""" + return self._location[1] + + @property + def location_name(self): + """Return a location name for the current location of the device.""" + return self._location_name + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + async def async_added_to_hass(self): + """Register state update callback.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, TRACKER_UPDATE, self._async_receive_data) + + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + self._unsub_dispatcher() + + @callback + def _async_receive_data(self, device, location, location_name): + """Update device data.""" + self._location_name = location_name + self._location = location + self.async_write_ha_state() diff --git a/homeassistant/components/locative/manifest.json b/homeassistant/components/locative/manifest.json index afe2850caf8..be2eb07a23c 100644 --- a/homeassistant/components/locative/manifest.json +++ b/homeassistant/components/locative/manifest.json @@ -1,6 +1,7 @@ { "domain": "locative", "name": "Locative", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/locative", "requirements": [], "dependencies": [ diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 70fe31e64d6..43fe9cb2d52 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -102,7 +102,7 @@ async def async_setup(hass, config): hass.http.register_view(LogbookView(config.get(DOMAIN, {}))) - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'logbook', 'logbook', 'hass:format-list-bulleted-type') hass.services.async_register( diff --git a/homeassistant/components/logi_circle/.translations/fr.json b/homeassistant/components/logi_circle/.translations/fr.json new file mode 100644 index 00000000000..7f8a2f2a098 --- /dev/null +++ b/homeassistant/components/logi_circle/.translations/fr.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Vous ne pouvez configurer qu'un seul compte Logi Circle.", + "external_error": "Une exception est survenue \u00e0 partir d'un autre flux.", + "external_setup": "Logi Circle a \u00e9t\u00e9 configur\u00e9 avec succ\u00e8s \u00e0 partir d'un autre flux.", + "no_flows": "Vous devez configurer Logi Circle avant de pouvoir vous authentifier aupr\u00e8s de celui-ci. [Veuillez lire les instructions] (https://www.home-assistant.io/components/logi_circle/)." + }, + "create_entry": { + "default": "Authentifi\u00e9 avec succ\u00e8s avec Logi Circle." + }, + "error": { + "auth_error": "L'autorisation de l'API a \u00e9chou\u00e9.", + "auth_timeout": "L'autorisation a expir\u00e9 lors de la demande du jeton d'acc\u00e8s.", + "follow_link": "Veuillez suivre le lien et vous authentifier avant d'appuyer sur Soumettre." + }, + "step": { + "auth": { + "description": "Suivez le lien ci-dessous et acceptez acc\u00e8s \u00e0 votre compte Logi Circle, puis revenez et appuyez sur Envoyer ci-dessous. \n\n [Lien] ( {authorization_url} )", + "title": "Authentifier avec Logi Circle" + }, + "user": { + "data": { + "flow_impl": "Fournisseur" + }, + "description": "Choisissez via quel fournisseur d'authentification vous souhaitez vous authentifier avec Logi Circle.", + "title": "Fournisseur d'authentification" + } + }, + "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 fe1708568bd..84af68e1384 100644 --- a/homeassistant/components/logi_circle/.translations/nl.json +++ b/homeassistant/components/logi_circle/.translations/nl.json @@ -1,5 +1,14 @@ { "config": { + "abort": { + "external_error": "Uitzondering opgetreden uit een andere stroom." + }, + "create_entry": { + "default": "Succesvol geverifieerd met Logi Circle." + }, + "error": { + "auth_error": "API-autorisatie mislukt." + }, "title": "Logi Circle" } } \ No newline at end of file diff --git a/homeassistant/components/logi_circle/.translations/pl.json b/homeassistant/components/logi_circle/.translations/pl.json index ab46b72fdac..2c155ffde61 100644 --- a/homeassistant/components/logi_circle/.translations/pl.json +++ b/homeassistant/components/logi_circle/.translations/pl.json @@ -4,7 +4,7 @@ "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko jedno konto Logi Circle.", "external_error": "Wyst\u0105pi\u0142 wyj\u0105tek zewn\u0119trzny.", "external_setup": "Logi Circle pomy\u015blnie skonfigurowano.", - "no_flows": "Musisz skonfigurowa\u0107 Logi Circle, zanim b\u0119dziesz m\u00f3g\u0142 si\u0119 z nim uwierzytelni\u0107. [Przeczytaj instrukcje](https://www.home-assistant.io/components/logi_circle/)." + "no_flows": "Musisz skonfigurowa\u0107 Logi Circle, zanim b\u0119dziesz m\u00f3g\u0142 si\u0119 z nim uwierzytelni\u0107. [Przeczytaj instrukcj\u0119](https://www.home-assistant.io/components/logi_circle/)." }, "create_entry": { "default": "Pomy\u015blnie uwierzytelniono z Logi Circle." diff --git a/homeassistant/components/logi_circle/.translations/sv.json b/homeassistant/components/logi_circle/.translations/sv.json index d7e1e1e251c..221d2a7a86b 100644 --- a/homeassistant/components/logi_circle/.translations/sv.json +++ b/homeassistant/components/logi_circle/.translations/sv.json @@ -1,13 +1,17 @@ { "config": { "abort": { - "already_setup": "Du kan endast konfigurera ett Logi Circle-konto." + "already_setup": "Du kan endast konfigurera ett Logi Circle-konto.", + "external_error": "Undantag intr\u00e4ffade fr\u00e5n ett annat fl\u00f6de.", + "external_setup": "Logi Circle har konfigurerats fr\u00e5n ett annat fl\u00f6de.", + "no_flows": "Du m\u00e5ste konfigurera Logi Circle innan du kan autentisera med den. [V\u00e4nligen l\u00e4s instruktionerna] (https://www.home-assistant.io/components/logi_circle/)." }, "create_entry": { "default": "Autentiserad med Logi Circle." }, "error": { "auth_error": "API autentiseringen misslyckades.", + "auth_timeout": "Godk\u00e4nnandet tog f\u00f6r l\u00e5ng tid vid beg\u00e4ran om \u00e5tkomsttoken.", "follow_link": "V\u00e4nligen f\u00f6lj l\u00e4nken och autentisera innan du trycker p\u00e5 Skicka." }, "step": { diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py index 1b74a9df03b..4e5ad0c5aeb 100644 --- a/homeassistant/components/logi_circle/__init__.py +++ b/homeassistant/components/logi_circle/__init__.py @@ -118,7 +118,7 @@ async def async_setup_entry(hass, entry): return False try: - with async_timeout.timeout(_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(_TIMEOUT): # Ensure the cameras property returns the same Camera objects for # all devices. Performs implicit login and session validation. await logi_circle.synchronize_cameras() diff --git a/homeassistant/components/logi_circle/config_flow.py b/homeassistant/components/logi_circle/config_flow.py index ba772fb0fed..728ca27ba51 100644 --- a/homeassistant/components/logi_circle/config_flow.py +++ b/homeassistant/components/logi_circle/config_flow.py @@ -160,7 +160,7 @@ class LogiCircleFlowHandler(config_entries.ConfigFlow): cache_file=DEFAULT_CACHEDB) try: - with async_timeout.timeout(_TIMEOUT, loop=self.hass.loop): + with async_timeout.timeout(_TIMEOUT): await logi_session.authorize(code) except AuthorizationFailed: (self.hass.data[DATA_FLOW_IMPL][DOMAIN] diff --git a/homeassistant/components/logi_circle/manifest.json b/homeassistant/components/logi_circle/manifest.json index 8cf6a157a01..b1767748395 100644 --- a/homeassistant/components/logi_circle/manifest.json +++ b/homeassistant/components/logi_circle/manifest.json @@ -1,6 +1,7 @@ { "domain": "logi_circle", "name": "Logi Circle", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/logi_circle", "requirements": ["logi_circle==0.2.2"], "dependencies": ["ffmpeg"], diff --git a/homeassistant/components/loopenergy/manifest.json b/homeassistant/components/loopenergy/manifest.json index b282755b1a0..20fe6fac2aa 100644 --- a/homeassistant/components/loopenergy/manifest.json +++ b/homeassistant/components/loopenergy/manifest.json @@ -3,7 +3,7 @@ "name": "Loopenergy", "documentation": "https://www.home-assistant.io/components/loopenergy", "requirements": [ - "pyloopenergy==0.1.2" + "pyloopenergy==0.1.3" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 03b1cf06d68..b1b9cf1a524 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -26,6 +26,7 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) +EVENT_LOVELACE_UPDATED = 'lovelace_updated' LOVELACE_CONFIG_FILE = 'ui-lovelace.yaml' @@ -52,7 +53,7 @@ async def async_setup(hass, config): # Pass in default to `get` because defaults not set if loaded as dep mode = config.get(DOMAIN, {}).get(CONF_MODE, MODE_STORAGE) - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( DOMAIN, config={ 'mode': mode }) @@ -83,6 +84,7 @@ class LovelaceStorage: """Initialize Lovelace config based on storage helper.""" self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) self._data = None + self._hass = hass async def async_get_info(self): """Return the YAML storage mode.""" @@ -115,6 +117,8 @@ class LovelaceStorage: self._data['config'] = config await self._store.async_save(self._data) + self._hass.bus.async_fire(EVENT_LOVELACE_UPDATED) + async def _load(self): """Load the config.""" data = await self._store.async_load() @@ -176,18 +180,19 @@ def handle_yaml_errors(func): error = None try: result = await func(hass, connection, msg) - message = websocket_api.result_message( - msg['id'], result - ) except ConfigNotFound: error = 'config_not_found', 'No config found.' except HomeAssistantError as err: error = 'error', str(err) if error is not None: - message = websocket_api.error_message(msg['id'], *error) + connection.send_error(msg['id'], *error) + return - connection.send_message(message) + if msg is not None: + await connection.send_big_result(msg['id'], result) + else: + connection.send_result(msg['id'], result) return send_with_error_handling diff --git a/homeassistant/components/lovelace/manifest.json b/homeassistant/components/lovelace/manifest.json index 1c1a7a107e4..dd8da40efe4 100644 --- a/homeassistant/components/lovelace/manifest.json +++ b/homeassistant/components/lovelace/manifest.json @@ -5,6 +5,6 @@ "requirements": [], "dependencies": [], "codeowners": [ - "@home-assistant/core" + "@home-assistant/frontend" ] } diff --git a/homeassistant/components/luftdaten/.translations/ko.json b/homeassistant/components/luftdaten/.translations/ko.json index 7d182cc1a0e..97af0e8ed9b 100644 --- a/homeassistant/components/luftdaten/.translations/ko.json +++ b/homeassistant/components/luftdaten/.translations/ko.json @@ -3,7 +3,7 @@ "error": { "communication_error": "Luftdaten API \uc640 \ud1b5\uc2e0 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", "invalid_sensor": "\uc13c\uc11c\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uac70\ub098 \uc720\ud6a8\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", - "sensor_exists": "\uc13c\uc11c\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4" + "sensor_exists": "\uc13c\uc11c\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/luftdaten/manifest.json b/homeassistant/components/luftdaten/manifest.json index 0e6a46a5c5d..d0a3d48b60f 100644 --- a/homeassistant/components/luftdaten/manifest.json +++ b/homeassistant/components/luftdaten/manifest.json @@ -1,6 +1,7 @@ { "domain": "luftdaten", "name": "Luftdaten", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/luftdaten", "requirements": [ "luftdaten==0.3.4" diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 8f851146464..3b5012ec160 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -30,7 +30,7 @@ SCAN_INTERVAL = timedelta(seconds=30) async def async_setup(hass, config): """Track states and offer events for mailboxes.""" mailboxes = [] - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'mailbox', 'mailbox', 'mdi:mailbox') hass.http.register_view(MailboxPlatformsView(mailboxes)) hass.http.register_view(MailboxMessageView(mailboxes)) @@ -82,7 +82,7 @@ async def async_setup(hass, config): in config_per_platform(config, DOMAIN)] if setup_tasks: - await asyncio.wait(setup_tasks, loop=hass.loop) + await asyncio.wait(setup_tasks) async def async_platform_discovered(platform, info): """Handle for discovered platform.""" @@ -241,9 +241,8 @@ class MailboxMediaView(MailboxView): """Retrieve media.""" mailbox = self.get_mailbox(platform) - hass = request.app['hass'] with suppress(asyncio.CancelledError, asyncio.TimeoutError): - with async_timeout.timeout(10, loop=hass.loop): + with async_timeout.timeout(10): try: stream = await mailbox.async_get_media(msgid) except StreamError as err: diff --git a/homeassistant/components/mailgun/__init__.py b/homeassistant/components/mailgun/__init__.py index 2f89904f12b..f74d105d98f 100644 --- a/homeassistant/components/mailgun/__init__.py +++ b/homeassistant/components/mailgun/__init__.py @@ -10,12 +10,14 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_WEBHOOK_ID from homeassistant.helpers import config_entry_flow +from .const import DOMAIN + + _LOGGER = logging.getLogger(__name__) CONF_SANDBOX = 'sandbox' DEFAULT_SANDBOX = False -DOMAIN = 'mailgun' MESSAGE_RECEIVED = '{}_message_received'.format(DOMAIN) @@ -90,13 +92,3 @@ async def async_unload_entry(hass, entry): # pylint: disable=invalid-name async_remove_entry = config_entry_flow.webhook_async_remove_entry - - -config_entry_flow.register_webhook_flow( - DOMAIN, - 'Mailgun Webhook', - { - 'mailgun_url': 'https://documentation.mailgun.com/en/latest/user_manual.html#webhooks', # noqa: E501 pylint: disable=line-too-long - 'docs_url': 'https://www.home-assistant.io/components/mailgun/' - } -) diff --git a/homeassistant/components/mailgun/config_flow.py b/homeassistant/components/mailgun/config_flow.py new file mode 100644 index 00000000000..aeccd9a506f --- /dev/null +++ b/homeassistant/components/mailgun/config_flow.py @@ -0,0 +1,13 @@ +"""Config flow for Mailgun.""" +from homeassistant.helpers import config_entry_flow +from .const import DOMAIN + + +config_entry_flow.register_webhook_flow( + DOMAIN, + 'Mailgun Webhook', + { + 'mailgun_url': 'https://documentation.mailgun.com/en/latest/user_manual.html#webhooks', # noqa: E501 pylint: disable=line-too-long + 'docs_url': 'https://www.home-assistant.io/components/mailgun/' + } +) diff --git a/homeassistant/components/mailgun/const.py b/homeassistant/components/mailgun/const.py new file mode 100644 index 00000000000..4532c1cbc46 --- /dev/null +++ b/homeassistant/components/mailgun/const.py @@ -0,0 +1,3 @@ +"""Const for Mailgun.""" + +DOMAIN = "mailgun" diff --git a/homeassistant/components/mailgun/manifest.json b/homeassistant/components/mailgun/manifest.json index 2979b391ec2..9ed7a50a8e3 100644 --- a/homeassistant/components/mailgun/manifest.json +++ b/homeassistant/components/mailgun/manifest.json @@ -1,6 +1,7 @@ { "domain": "mailgun", "name": "Mailgun", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/mailgun", "requirements": [ "pymailgunner==1.4" diff --git a/homeassistant/components/map/__init__.py b/homeassistant/components/map/__init__.py index df8ac49a6d5..ab89ccf23ce 100644 --- a/homeassistant/components/map/__init__.py +++ b/homeassistant/components/map/__init__.py @@ -4,6 +4,6 @@ DOMAIN = 'map' async def async_setup(hass, config): """Register the built-in map panel.""" - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'map', 'map', 'hass:tooltip-account') return True diff --git a/homeassistant/components/marytts/tts.py b/homeassistant/components/marytts/tts.py index 294383cb4dd..a17b95d1711 100644 --- a/homeassistant/components/marytts/tts.py +++ b/homeassistant/components/marytts/tts.py @@ -75,7 +75,7 @@ class MaryTTSProvider(Provider): actual_language = re.sub('-', '_', language) try: - with async_timeout.timeout(10, loop=self.hass.loop): + with async_timeout.timeout(10): url = 'http://{}:{}/process?'.format(self._host, self._port) audio = self._codec.upper() diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index 730fe866a5d..6db3791c519 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.0" + "Mastodon.py==1.4.2" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/mcp23017/__init__.py b/homeassistant/components/mcp23017/__init__.py new file mode 100644 index 00000000000..350ebc7f71d --- /dev/null +++ b/homeassistant/components/mcp23017/__init__.py @@ -0,0 +1,3 @@ +"""Support for I2C MCP23017 chip.""" + +DOMAIN = 'mcp23017' diff --git a/homeassistant/components/mcp23017/binary_sensor.py b/homeassistant/components/mcp23017/binary_sensor.py new file mode 100644 index 00000000000..6934468ec1c --- /dev/null +++ b/homeassistant/components/mcp23017/binary_sensor.py @@ -0,0 +1,89 @@ +"""Support for binary sensor using I2C MCP23017 chip.""" +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import DEVICE_DEFAULT_NAME +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_INVERT_LOGIC = 'invert_logic' +CONF_I2C_ADDRESS = 'i2c_address' +CONF_PINS = 'pins' +CONF_PULL_MODE = 'pull_mode' + +MODE_UP = 'UP' +MODE_DOWN = 'DOWN' + +DEFAULT_INVERT_LOGIC = False +DEFAULT_I2C_ADDRESS = 0x20 +DEFAULT_PULL_MODE = MODE_UP + +_SENSORS_SCHEMA = vol.Schema({ + cv.positive_int: cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PINS): _SENSORS_SCHEMA, + vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean, + vol.Optional(CONF_PULL_MODE, default=DEFAULT_PULL_MODE): + vol.All(vol.Upper, vol.In([MODE_UP, MODE_DOWN])), + vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): + vol.Coerce(int), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the MCP23017 binary sensors.""" + import board + import busio + import adafruit_mcp230xx + + pull_mode = config[CONF_PULL_MODE] + invert_logic = config[CONF_INVERT_LOGIC] + i2c_address = config[CONF_I2C_ADDRESS] + + i2c = busio.I2C(board.SCL, board.SDA) + mcp = adafruit_mcp230xx.MCP23017(i2c, address=i2c_address) + + binary_sensors = [] + pins = config[CONF_PINS] + + for pin_num, pin_name in pins.items(): + pin = mcp.get_pin(pin_num) + binary_sensors.append(MCP23017BinarySensor( + pin_name, pin, pull_mode, invert_logic)) + + add_devices(binary_sensors, True) + + +class MCP23017BinarySensor(BinarySensorDevice): + """Represent a binary sensor that uses MCP23017.""" + + def __init__(self, name, pin, pull_mode, invert_logic): + """Initialize the MCP23017 binary sensor.""" + import digitalio + self._name = name or DEVICE_DEFAULT_NAME + self._pin = pin + self._pull_mode = pull_mode + self._invert_logic = invert_logic + self._state = None + self._pin.direction = digitalio.Direction.INPUT + self._pin.pull = digitalio.Pull.UP + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the entity.""" + return self._state != self._invert_logic + + def update(self): + """Update the GPIO state.""" + self._state = self._pin.value diff --git a/homeassistant/components/mcp23017/manifest.json b/homeassistant/components/mcp23017/manifest.json new file mode 100644 index 00000000000..41048683c92 --- /dev/null +++ b/homeassistant/components/mcp23017/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "mcp23017", + "name": "MCP23017 I/O Expander", + "documentation": "https://www.home-assistant.io/components/mcp23017", + "requirements": [ + "RPi.GPIO==0.6.5", + "adafruit-blinka==1.2.1", + "adafruit-circuitpython-mcp230xx==1.1.2" + ], + "dependencies": [], + "codeowners": ["@jardiamj"] +} diff --git a/homeassistant/components/mcp23017/switch.py b/homeassistant/components/mcp23017/switch.py new file mode 100644 index 00000000000..8638b793a65 --- /dev/null +++ b/homeassistant/components/mcp23017/switch.py @@ -0,0 +1,97 @@ +"""Support for switch sensor using I2C MCP23017 chip.""" +import logging + +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.helpers.entity import ToggleEntity +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_INVERT_LOGIC = 'invert_logic' +CONF_I2C_ADDRESS = 'i2c_address' +CONF_PINS = 'pins' +CONF_PULL_MODE = 'pull_mode' + +DEFAULT_INVERT_LOGIC = False +DEFAULT_I2C_ADDRESS = 0x20 + +_SWITCHES_SCHEMA = vol.Schema({ + cv.positive_int: cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PINS): _SWITCHES_SCHEMA, + vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean, + vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): + vol.Coerce(int), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the MCP23017 devices.""" + import board + import busio + import adafruit_mcp230xx + + invert_logic = config.get(CONF_INVERT_LOGIC) + i2c_address = config.get(CONF_I2C_ADDRESS) + + i2c = busio.I2C(board.SCL, board.SDA) + mcp = adafruit_mcp230xx.MCP23017(i2c, address=i2c_address) + + switches = [] + pins = config.get(CONF_PINS) + for pin_num, pin_name in pins.items(): + pin = mcp.get_pin(pin_num) + switches.append(MCP23017Switch(pin_name, pin, invert_logic)) + add_entities(switches) + + +class MCP23017Switch(ToggleEntity): + """Representation of a MCP23017 output pin.""" + + def __init__(self, name, pin, invert_logic): + """Initialize the pin.""" + import digitalio + self._name = name or DEVICE_DEFAULT_NAME + self._pin = pin + self._invert_logic = invert_logic + self._state = False + + self._pin.direction = digitalio.Direction.OUTPUT + self._pin.value = self._invert_logic + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def assumed_state(self): + """Return true if optimistic updates are used.""" + return True + + def turn_on(self, **kwargs): + """Turn the device on.""" + self._pin.value = not self._invert_logic + self._state = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn the device off.""" + self._pin.value = self._invert_logic + self._state = False + self.schedule_update_ha_state() diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 31086eab83d..dbdb64b8421 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.04.30" + "youtube_dl==2019.05.11" ], "dependencies": [ "media_player" diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index ccfa968fa9a..63e2a127fd7 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -777,7 +777,7 @@ async def _async_fetch_image(hass, url): url = hass.config.api.base_url + url if url not in cache_images: - cache_images[url] = {CACHE_LOCK: asyncio.Lock(loop=hass.loop)} + cache_images[url] = {CACHE_LOCK: asyncio.Lock()} async with cache_images[url][CACHE_LOCK]: if CACHE_CONTENT in cache_images[url]: @@ -786,7 +786,7 @@ async def _async_fetch_image(hass, url): content, content_type = (None, None) websession = async_get_clientsession(hass) try: - with async_timeout.timeout(10, loop=hass.loop): + with async_timeout.timeout(10): response = await websession.get(url) if response.status == 200: @@ -869,8 +869,8 @@ async def websocket_handle_thumbnail(hass, connection, msg): 'Failed to fetch thumbnail')) return - connection.send_message(websocket_api.result_message( + await connection.send_big_result( msg['id'], { 'content_type': content_type, 'content': base64.b64encode(data).decode('utf-8') - })) + }) diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index 25b74698da6..157ebe9d3aa 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -277,7 +277,7 @@ class MicrosoftFace: tasks.append(self._entities[g_id].async_update_ha_state()) if tasks: - await asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks) async def call_api(self, method, function, data=None, binary=False, params=None): @@ -297,7 +297,7 @@ class MicrosoftFace: payload = None try: - with async_timeout.timeout(self.timeout, loop=self.hass.loop): + with async_timeout.timeout(self.timeout): response = await getattr(self.websession, method)( url, data=payload, headers=headers, params=params) diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index b9aa9c3e186..697ed8c52a8 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -112,7 +112,7 @@ class MjpegCamera(Camera): verify_ssl=self._verify_ssl ) try: - with async_timeout.timeout(10, loop=self.hass.loop): + with async_timeout.timeout(10): response = await websession.get( self._still_image_url, auth=self._auth) diff --git a/homeassistant/components/mobile_app/.translations/sv.json b/homeassistant/components/mobile_app/.translations/sv.json index bdd94b84a1a..4f9570146f2 100644 --- a/homeassistant/components/mobile_app/.translations/sv.json +++ b/homeassistant/components/mobile_app/.translations/sv.json @@ -1,9 +1,14 @@ { "config": { + "abort": { + "install_app": "\u00d6ppna mobilappen f\u00f6r att konfigurera integrationen med Home Assistant. Se [docs] ({apps_url}) f\u00f6r en lista \u00f6ver kompatibla appar." + }, "step": { "confirm": { - "description": "Vill du st\u00e4lla in mobilappkomponenten?" + "description": "Vill du konfigurera komponenten Mobile App?", + "title": "Mobilapp" } - } + }, + "title": "Mobilapp" } } \ No newline at end of file diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 711963a0b24..1d34babe3ac 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -1,5 +1,4 @@ """Integrates Native Apps to Home Assistant.""" -from homeassistant import config_entries from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.components.webhook import async_register as webhook_register from homeassistant.helpers import device_registry as dr, discovery @@ -8,13 +7,15 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, DATA_BINARY_SENSOR, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, - DATA_DEVICES, DATA_SENSOR, DATA_STORE, DOMAIN, STORAGE_KEY, - STORAGE_VERSION) + DATA_DEVICES, DATA_SENSOR, DATA_STORE, + DOMAIN, STORAGE_KEY, STORAGE_VERSION) from .http_api import RegistrationsView from .webhook import handle_webhook from .websocket_api import register_websocket_handlers +PLATFORMS = 'sensor', 'binary_sensor', 'device_tracker' + async def async_setup(hass: HomeAssistantType, config: ConfigType): """Set up the mobile app component.""" @@ -25,7 +26,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): DATA_BINARY_SENSOR: {}, DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], - DATA_DEVICES: {}, DATA_SENSOR: {} } @@ -84,33 +84,8 @@ async def async_setup_entry(hass, entry): webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, - DATA_BINARY_SENSOR)) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, DATA_SENSOR)) + for domain in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, domain)) return True - - -@config_entries.HANDLERS.register(DOMAIN) -class MobileAppFlowHandler(config_entries.ConfigFlow): - """Handle a Mobile App config flow.""" - - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH - - async def async_step_user(self, user_input=None): - """Handle a flow initialized by the user.""" - placeholders = { - 'apps_url': - 'https://www.home-assistant.io/components/mobile_app/#apps' - } - - return self.async_abort(reason='install_app', - description_placeholders=placeholders) - - async def async_step_registration(self, user_input=None): - """Handle a flow initialized during registration.""" - return self.async_create_entry(title=user_input[ATTR_DEVICE_NAME], - data=user_input) diff --git a/homeassistant/components/mobile_app/config_flow.py b/homeassistant/components/mobile_app/config_flow.py new file mode 100644 index 00000000000..02fea3c6593 --- /dev/null +++ b/homeassistant/components/mobile_app/config_flow.py @@ -0,0 +1,26 @@ +"""Config flow for Mobile App.""" +from homeassistant import config_entries +from .const import DOMAIN, ATTR_DEVICE_NAME + + +@config_entries.HANDLERS.register(DOMAIN) +class MobileAppFlowHandler(config_entries.ConfigFlow): + """Handle a Mobile App config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + placeholders = { + 'apps_url': + 'https://www.home-assistant.io/components/mobile_app/#apps' + } + + return self.async_abort(reason='install_app', + description_placeholders=placeholders) + + async def async_step_registration(self, user_input=None): + """Handle a flow initialized during registration.""" + return self.async_create_entry(title=user_input[ATTR_DEVICE_NAME], + data=user_input) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 8b33406216e..922835c1d40 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -160,6 +160,7 @@ SENSOR_TYPES = [ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR] COMBINED_CLASSES = sorted(set(BINARY_SENSOR_CLASSES + SENSOR_CLASSES)) SIGNAL_SENSOR_UPDATE = DOMAIN + '_sensor_update' +SIGNAL_LOCATION_UPDATE = DOMAIN + '_location_update_{}' REGISTER_SENSOR_SCHEMA = vol.Schema({ vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict, diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py new file mode 100644 index 00000000000..7fb76f3af41 --- /dev/null +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -0,0 +1,167 @@ +"""Device tracker platform that adds support for OwnTracks over MQTT.""" +import logging + +from homeassistant.core import callback +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_BATTERY_LEVEL, +) +from homeassistant.components.device_tracker.const import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import ( + DeviceTrackerEntity +) +from homeassistant.helpers.restore_state import RestoreEntity +from .const import ( + ATTR_ALTITUDE, + ATTR_BATTERY, + ATTR_COURSE, + ATTR_DEVICE_ID, + ATTR_DEVICE_NAME, + ATTR_GPS_ACCURACY, + ATTR_GPS, + ATTR_LOCATION_NAME, + ATTR_SPEED, + ATTR_VERTICAL_ACCURACY, + + SIGNAL_LOCATION_UPDATE, +) +from .helpers import device_info + +_LOGGER = logging.getLogger(__name__) +ATTR_KEYS = ( + ATTR_ALTITUDE, + ATTR_COURSE, + ATTR_SPEED, + ATTR_VERTICAL_ACCURACY +) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up OwnTracks based off an entry.""" + entity = MobileAppEntity(entry) + async_add_entities([entity]) + return True + + +class MobileAppEntity(DeviceTrackerEntity, RestoreEntity): + """Represent a tracked device.""" + + def __init__(self, entry, data=None): + """Set up OwnTracks entity.""" + self._entry = entry + self._data = data + self._dispatch_unsub = None + + @property + def unique_id(self): + """Return the unique ID.""" + return self._entry.data[ATTR_DEVICE_ID] + + @property + def battery_level(self): + """Return the battery level of the device.""" + return self._data.get(ATTR_BATTERY) + + @property + def device_state_attributes(self): + """Return device specific attributes.""" + attrs = {} + for key in ATTR_KEYS: + value = self._data.get(key) + if value is not None: + attrs[key] = value + + return attrs + + @property + def location_accuracy(self): + """Return the gps accuracy of the device.""" + return self._data.get(ATTR_GPS_ACCURACY) + + @property + def latitude(self): + """Return latitude value of the device.""" + gps = self._data.get(ATTR_GPS) + + if gps is None: + return None + + return gps[0] + + @property + def longitude(self): + """Return longitude value of the device.""" + gps = self._data.get(ATTR_GPS) + + if gps is None: + return None + + return gps[1] + + @property + def location_name(self): + """Return a location name for the current location of the device.""" + return self._data.get(ATTR_LOCATION_NAME) + + @property + def name(self): + """Return the name of the device.""" + return self._entry.data[ATTR_DEVICE_NAME] + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + @property + def device_info(self): + """Return the device info.""" + return device_info(self._entry.data) + + async def async_added_to_hass(self): + """Call when entity about to be added to Home Assistant.""" + await super().async_added_to_hass() + self._dispatch_unsub = \ + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_LOCATION_UPDATE.format(self._entry.entry_id), + self.update_data + ) + + # Don't restore if we got set up with data. + if self._data is not None: + return + + state = await self.async_get_last_state() + + if state is None: + self._data = {} + return + + attr = state.attributes + data = { + ATTR_GPS: (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)), + ATTR_GPS_ACCURACY: attr.get(ATTR_GPS_ACCURACY), + ATTR_BATTERY: attr.get(ATTR_BATTERY_LEVEL), + } + data.update({key: attr[key] for key in attr if key in ATTR_KEYS}) + self._data = data + + async def async_will_remove_from_hass(self): + """Call when entity is being removed from hass.""" + await super().async_will_remove_from_hass() + + if self._dispatch_unsub: + self._dispatch_unsub() + self._dispatch_unsub = None + + @callback + def update_data(self, data): + """Mark the device as seen.""" + self._data = data + self.async_write_ha_state() diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index eca9d2b024b..8c1747d6f2b 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -6,11 +6,11 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, - ATTR_MODEL, ATTR_OS_VERSION, ATTR_SENSOR_ATTRIBUTES, +from .const import (ATTR_SENSOR_ATTRIBUTES, ATTR_SENSOR_DEVICE_CLASS, ATTR_SENSOR_ICON, ATTR_SENSOR_NAME, ATTR_SENSOR_TYPE, ATTR_SENSOR_UNIQUE_ID, DOMAIN, SIGNAL_SENSOR_UPDATE) +from .helpers import device_info def sensor_id(webhook_id, unique_id): @@ -76,17 +76,7 @@ class MobileAppEntity(Entity): @property def device_info(self): """Return device registry information for this entity.""" - return { - 'identifiers': { - (ATTR_DEVICE_ID, self._registration[ATTR_DEVICE_ID]), - (CONF_WEBHOOK_ID, self._registration[CONF_WEBHOOK_ID]) - }, - 'manufacturer': self._registration[ATTR_MANUFACTURER], - 'model': self._registration[ATTR_MODEL], - 'device_name': self._registration[ATTR_DEVICE_NAME], - 'sw_version': self._registration[ATTR_OS_VERSION], - 'config_entries': self._device.config_entries - } + return device_info(self._registration) async def async_update(self): """Get the latest state of the sensor.""" diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 6aec4307464..30c111fe0b4 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -9,7 +9,7 @@ from homeassistant.core import Context from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import HomeAssistantType -from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME, +from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME, ATTR_DEVICE_ID, ATTR_APP_VERSION, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, ATTR_SUPPORTS_ENCRYPTION, CONF_SECRET, CONF_USER_ID, DATA_BINARY_SENSOR, @@ -148,3 +148,16 @@ def webhook_response(data, *, registration: Dict, status: int = 200, return Response(text=data, status=status, content_type='application/json', headers=headers) + + +def device_info(registration: Dict) -> Dict: + """Return the device info for this registration.""" + return { + 'identifiers': { + (DOMAIN, registration[ATTR_DEVICE_ID]), + }, + 'manufacturer': registration[ATTR_MANUFACTURER], + 'model': registration[ATTR_MODEL], + 'device_name': registration[ATTR_DEVICE_NAME], + 'sw_version': registration[ATTR_OS_VERSION], + } diff --git a/homeassistant/components/mobile_app/manifest.json b/homeassistant/components/mobile_app/manifest.json index 9c21858df1d..85c6231daa8 100644 --- a/homeassistant/components/mobile_app/manifest.json +++ b/homeassistant/components/mobile_app/manifest.json @@ -1,12 +1,12 @@ { "domain": "mobile_app", "name": "Home Assistant Mobile App Support", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/mobile_app", "requirements": [ "PyNaCl==1.3.0" ], "dependencies": [ - "device_tracker", "http", "webhook" ], diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index a69c020cfc8..e10ebf13c4c 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -89,13 +89,12 @@ class MobileAppNotificationService(BaseNotificationService): targets = kwargs.get(ATTR_TARGET) if not targets: - targets = push_registrations(self.hass) + targets = push_registrations(self.hass).values() if kwargs.get(ATTR_DATA) is not None: data[ATTR_DATA] = kwargs.get(ATTR_DATA) for target in targets: - entry = self.hass.data[DOMAIN][DATA_CONFIG_ENTRIES][target] entry_data = entry.data @@ -115,7 +114,7 @@ class MobileAppNotificationService(BaseNotificationService): data['registration_info'] = reg_info try: - with async_timeout.timeout(10, loop=self.hass.loop): + with async_timeout.timeout(10): response = await self._session.post(push_url, json=data) result = await response.json() diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 1ef5f4ce531..40002b5cfec 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -6,10 +6,6 @@ import voluptuous as vol from homeassistant.components.cloud import (async_remote_ui_url, CloudNotAvailable) -from homeassistant.components.device_tracker import (ATTR_ATTRIBUTES, - ATTR_DEV_ID, - DOMAIN as DT_DOMAIN, - SERVICE_SEE as DT_SEE) from homeassistant.components.frontend import MANIFEST_JSON from homeassistant.components.zone.const import DOMAIN as ZONE_DOMAIN @@ -24,13 +20,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.template import attach from homeassistant.helpers.typing import HomeAssistantType -from .const import (ATTR_ALTITUDE, ATTR_BATTERY, ATTR_COURSE, ATTR_DEVICE_ID, +from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_EVENT_DATA, ATTR_EVENT_TYPE, - ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_LOCATION_NAME, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, - ATTR_SENSOR_TYPE, ATTR_SENSOR_UNIQUE_ID, ATTR_SPEED, + ATTR_SENSOR_TYPE, ATTR_SENSOR_UNIQUE_ID, ATTR_SUPPORTS_ENCRYPTION, ATTR_TEMPLATE, - ATTR_TEMPLATE_VARIABLES, ATTR_VERTICAL_ACCURACY, + ATTR_TEMPLATE_VARIABLES, ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED, ATTR_WEBHOOK_ENCRYPTED_DATA, ATTR_WEBHOOK_TYPE, CONF_CLOUDHOOK_URL, CONF_REMOTE_UI_URL, CONF_SECRET, @@ -43,7 +38,7 @@ from .const import (ATTR_ALTITUDE, ATTR_BATTERY, ATTR_COURSE, ATTR_DEVICE_ID, WEBHOOK_TYPE_REGISTER_SENSOR, WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, WEBHOOK_TYPE_UPDATE_REGISTRATION, - WEBHOOK_TYPE_UPDATE_SENSOR_STATES) + WEBHOOK_TYPE_UPDATE_SENSOR_STATES, SIGNAL_LOCATION_UPDATE) from .helpers import (_decrypt_payload, empty_okay_response, error_response, @@ -149,37 +144,9 @@ async def handle_webhook(hass: HomeAssistantType, webhook_id: str, headers=headers) if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION: - see_payload = { - ATTR_DEV_ID: registration[ATTR_DEVICE_ID], - ATTR_GPS: data[ATTR_GPS], - ATTR_GPS_ACCURACY: data[ATTR_GPS_ACCURACY], - } - - for key in (ATTR_LOCATION_NAME, ATTR_BATTERY): - value = data.get(key) - if value is not None: - see_payload[key] = value - - attrs = {} - - for key in (ATTR_ALTITUDE, ATTR_COURSE, - ATTR_SPEED, ATTR_VERTICAL_ACCURACY): - value = data.get(key) - if value is not None: - attrs[key] = value - - if attrs: - see_payload[ATTR_ATTRIBUTES] = attrs - - try: - await hass.services.async_call(DT_DOMAIN, - DT_SEE, see_payload, - blocking=True, context=context) - # noqa: E722 pylint: disable=broad-except - except (vol.Invalid, ServiceNotFound, Exception) as ex: - _LOGGER.error("Error when updating location during mobile_app " - "webhook (device name: %s): %s", - registration[ATTR_DEVICE_NAME], ex) + hass.helpers.dispatcher.async_dispatcher_send( + SIGNAL_LOCATION_UPDATE.format(config_entry.entry_id), data + ) return empty_okay_response(headers=headers) if webhook_type == WEBHOOK_TYPE_UPDATE_REGISTRATION: diff --git a/homeassistant/components/moon/.translations/sensor.sv.json b/homeassistant/components/moon/.translations/sensor.sv.json index ae69c1c9654..1cd7596ba0f 100644 --- a/homeassistant/components/moon/.translations/sensor.sv.json +++ b/homeassistant/components/moon/.translations/sensor.sv.json @@ -1,12 +1,12 @@ { "state": { - "first_quarter": "F\u00f6rsta kvartalet", + "first_quarter": "F\u00f6rsta halvm\u00e5ne", "full_moon": "Fullm\u00e5ne", - "last_quarter": "Sista kvartalet", + "last_quarter": "Sista halvm\u00e5ne", "new_moon": "Nym\u00e5ne", - "waning_crescent": "Avtagande halvm\u00e5ne", + "waning_crescent": "Avtagande m\u00e5nsk\u00e4ra", "waning_gibbous": "Avtagande halvm\u00e5ne", - "waxing_crescent": "Tilltagande halvm\u00e5ne", + "waxing_crescent": "Tilltagande m\u00e5nsk\u00e4ra", "waxing_gibbous": "Tilltagande halvm\u00e5ne" } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 3de53145cfc..4ba8f1a5cc5 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -651,7 +651,7 @@ class MQTT: self.birth_message = birth_message self.connected = False self._mqttc = None # type: mqtt.Client - self._paho_lock = asyncio.Lock(loop=hass.loop) + self._paho_lock = asyncio.Lock() if protocol == PROTOCOL_31: proto = mqtt.MQTTv31 # type: int diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json index dd4d0323a51..d63d1707fac 100644 --- a/homeassistant/components/mqtt/manifest.json +++ b/homeassistant/components/mqtt/manifest.json @@ -1,6 +1,7 @@ { "domain": "mqtt", "name": "MQTT", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/mqtt", "requirements": [ "hbmqtt==0.9.4", diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 19f8b82a669..b0d8c4dfb3e 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -151,9 +151,9 @@ async def finish_setup(hass, hass_config, gateways): start_tasks.append(_gw_start(hass, gateway)) if discover_tasks: # Make sure all devices and platforms are loaded before gateway start. - await asyncio.wait(discover_tasks, loop=hass.loop) + await asyncio.wait(discover_tasks) if start_tasks: - await asyncio.wait(start_tasks, loop=hass.loop) + await asyncio.wait(start_tasks) async def _discover_persistent_devices(hass, hass_config, gateway): @@ -172,7 +172,7 @@ async def _discover_persistent_devices(hass, hass_config, gateway): tasks.append(discover_mysensors_platform( hass, hass_config, platform, dev_ids)) if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) async def _gw_start(hass, gateway): @@ -196,7 +196,7 @@ async def _gw_start(hass, gateway): hass.data[gateway_ready_key] = gateway_ready try: - with async_timeout.timeout(GATEWAY_READY_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(GATEWAY_READY_TIMEOUT): await gateway_ready except asyncio.TimeoutError: _LOGGER.warning( diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index 061d8fd04c8..8bbf07f2091 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -159,6 +159,8 @@ class NeatoConnectedVacuum(StateVacuumDevice): self._clean_state = STATE_ERROR self._status_state = ERRORS.get(self._state['error']) + self._battery_level = self._state['details']['charge'] + if not self._mapdata.get(self._robot_serial, {}).get('maps', []): return self.clean_time_start = ( @@ -182,8 +184,6 @@ class NeatoConnectedVacuum(StateVacuumDevice): self.clean_battery_end = ( self._mapdata[self._robot_serial]['maps'][0]['run_charge_at_end']) - self._battery_level = self._state['details']['charge'] - if self._robot_has_map: if self._state['availableServices']['maps'] != "basic-1": if self._robot_maps[self._robot_serial]: diff --git a/homeassistant/components/nest/.translations/pl.json b/homeassistant/components/nest/.translations/pl.json index c03b2eff0fa..ec33346cdf8 100644 --- a/homeassistant/components/nest/.translations/pl.json +++ b/homeassistant/components/nest/.translations/pl.json @@ -4,7 +4,7 @@ "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko jedno konto Nest.", "authorize_url_fail": "Nieznany b\u0142\u0105d podczas generowania url autoryzacji.", "authorize_url_timeout": "Min\u0105\u0142 limit czasu generowania url autoryzacji.", - "no_flows": "Musisz skonfigurowa\u0107 Nest, zanim b\u0119dziesz m\u00f3g\u0142 wykona\u0107 uwierzytelnienie. [Przeczytaj instrukcje](https://www.home-assistant.io/components/nest/)." + "no_flows": "Musisz skonfigurowa\u0107 Nest, zanim b\u0119dziesz m\u00f3g\u0142 wykona\u0107 uwierzytelnienie. [Przeczytaj instrukcj\u0119](https://www.home-assistant.io/components/nest/)." }, "error": { "internal_error": "Wewn\u0119trzny b\u0142\u0105d sprawdzania poprawno\u015bci kodu", diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 9f2e4202f93..8a6e8ec611a 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -1,6 +1,7 @@ { "domain": "nest", "name": "Nest", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/nest", "requirements": [ "python-nest==4.1.0" diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 976e0794938..7a0c1b0e513 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -123,8 +123,7 @@ class NetatmoCamera(Camera): """Return supported features.""" return SUPPORT_STREAM - @property - def stream_source(self): + async def stream_source(self): """Return the stream source.""" url = '{0}/live/files/{1}/index.m3u8' if self._localurl: diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index a5e4e8aa7a7..91e96e48b5c 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.11" + "pyatmo==1.12" ], "dependencies": [ "webhook" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 7b71eaf659c..dabfb827aea 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -145,7 +145,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # Only create sensors for monitored properties for condition in monitored_conditions: dev.append(NetatmoSensor( - data, module_name, condition.lower())) + data, module_name, condition.lower(), + config.get(CONF_STATION))) for module_name, _ in not_handled.items(): _LOGGER.error('Module name: "%s" not found', module_name) @@ -164,13 +165,14 @@ def all_product_classes(): class NetatmoSensor(Entity): """Implementation of a Netatmo sensor.""" - def __init__(self, netatmo_data, module_name, sensor_type): + def __init__(self, netatmo_data, module_name, sensor_type, station): """Initialize the sensor.""" self._name = 'Netatmo {} {}'.format(module_name, SENSOR_TYPES[sensor_type][0]) self.netatmo_data = netatmo_data self.module_name = module_name self.type = sensor_type + self.station_name = station self._state = None self._device_class = SENSOR_TYPES[self.type][3] self._icon = SENSOR_TYPES[self.type][2] @@ -178,7 +180,8 @@ class NetatmoSensor(Entity): self._module_type = self.netatmo_data. \ station_data.moduleByName(module=module_name)['type'] module_id = self.netatmo_data. \ - station_data.moduleByName(module=module_name)['_id'] + station_data.moduleByName(station=self.station_name, + module=module_name)['_id'] self._unique_id = '{}-{}'.format(module_id, self.type) @property @@ -523,9 +526,9 @@ class NetatmoData: _LOGGER.debug("%s detected!", str(self.data_class.__name__)) return station_data except NoDevice: - _LOGGER.error("No Weather or HomeCoach devices found for %s", str( - self.station - )) + _LOGGER.warning("No Weather or HomeCoach devices found for %s", + str(self.station) + ) raise def update(self): @@ -547,10 +550,14 @@ class NetatmoData: try: if self.station is not None: - self.data = self.station_data.lastData( + data = self.station_data.lastData( station=self.station, exclude=3600) else: - self.data = self.station_data.lastData(exclude=3600) + data = self.station_data.lastData(exclude=3600) + if not data: + self._next_update = time() + NETATMO_UPDATE_INTERVAL + return + self.data = data newinterval = 0 try: diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json index 8fbf185c6af..3ee3b189939 100644 --- a/homeassistant/components/netgear/manifest.json +++ b/homeassistant/components/netgear/manifest.json @@ -3,7 +3,7 @@ "name": "Netgear", "documentation": "https://www.home-assistant.io/components/netgear", "requirements": [ - "pynetgear==0.5.2" + "pynetgear==0.6.1" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/no_ip/__init__.py b/homeassistant/components/no_ip/__init__.py index 6a714747484..13bc4c2aa4b 100644 --- a/homeassistant/components/no_ip/__init__.py +++ b/homeassistant/components/no_ip/__init__.py @@ -89,7 +89,7 @@ async def _update_no_ip(hass, session, domain, auth_str, timeout): } try: - with async_timeout.timeout(timeout, loop=hass.loop): + with async_timeout.timeout(timeout): resp = await session.get(url, params=params, headers=headers) body = await resp.text() diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 8bb3384aebd..42beb7a65b6 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -139,7 +139,7 @@ async def async_setup(hass, config): in config_per_platform(config, DOMAIN)] if setup_tasks: - await asyncio.wait(setup_tasks, loop=hass.loop) + await asyncio.wait(setup_tasks) async def async_platform_discovered(platform, info): """Handle for discovered platform.""" diff --git a/homeassistant/components/onboarding/.translations/ca.json b/homeassistant/components/onboarding/.translations/ca.json new file mode 100644 index 00000000000..894bfe51674 --- /dev/null +++ b/homeassistant/components/onboarding/.translations/ca.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Dormitori", + "kitchen": "Cuina", + "living_room": "Sala d'estar" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/de.json b/homeassistant/components/onboarding/.translations/de.json new file mode 100644 index 00000000000..e44387f8008 --- /dev/null +++ b/homeassistant/components/onboarding/.translations/de.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Schlafzimmer", + "kitchen": "K\u00fcche", + "living_room": "Wohnzimmer" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/es.json b/homeassistant/components/onboarding/.translations/es.json new file mode 100644 index 00000000000..4c67fe20910 --- /dev/null +++ b/homeassistant/components/onboarding/.translations/es.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Dormitorio", + "kitchen": "Cocina", + "living_room": "Sal\u00f3n" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/fr.json b/homeassistant/components/onboarding/.translations/fr.json new file mode 100644 index 00000000000..8a8ff47a48a --- /dev/null +++ b/homeassistant/components/onboarding/.translations/fr.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Chambre", + "kitchen": "Cuisine", + "living_room": "Salle De S\u00e9jour" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/ko.json b/homeassistant/components/onboarding/.translations/ko.json new file mode 100644 index 00000000000..54d8ad6a7b7 --- /dev/null +++ b/homeassistant/components/onboarding/.translations/ko.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "\uce68\uc2e4", + "kitchen": "\uc8fc\ubc29", + "living_room": "\uac70\uc2e4" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/nl.json b/homeassistant/components/onboarding/.translations/nl.json new file mode 100644 index 00000000000..ed9314973fb --- /dev/null +++ b/homeassistant/components/onboarding/.translations/nl.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Slaapkamer", + "kitchen": "Keuken", + "living_room": "Woonkamer" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/no.json b/homeassistant/components/onboarding/.translations/no.json new file mode 100644 index 00000000000..04f8359d026 --- /dev/null +++ b/homeassistant/components/onboarding/.translations/no.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Soverom", + "kitchen": "Kj\u00f8kken", + "living_room": "Stue" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/pl.json b/homeassistant/components/onboarding/.translations/pl.json new file mode 100644 index 00000000000..446ce7115aa --- /dev/null +++ b/homeassistant/components/onboarding/.translations/pl.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Sypialnia", + "kitchen": "Kuchnia", + "living_room": "Salon" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/sl.json b/homeassistant/components/onboarding/.translations/sl.json new file mode 100644 index 00000000000..c340a26a5c8 --- /dev/null +++ b/homeassistant/components/onboarding/.translations/sl.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Spalnica", + "kitchen": "Kuhinja", + "living_room": "Dnevna soba" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/sv.json b/homeassistant/components/onboarding/.translations/sv.json new file mode 100644 index 00000000000..4aec4ab353e --- /dev/null +++ b/homeassistant/components/onboarding/.translations/sv.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Sovrum", + "kitchen": "K\u00f6k", + "living_room": "Vardagsrum" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/zh-Hans.json b/homeassistant/components/onboarding/.translations/zh-Hans.json new file mode 100644 index 00000000000..3c38aa22985 --- /dev/null +++ b/homeassistant/components/onboarding/.translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "\u5367\u5ba4", + "kitchen": "\u53a8\u623f", + "living_room": "\u5ba2\u5385" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index 55bba8f4efe..f5ed1a9b271 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -3,10 +3,11 @@ from homeassistant.core import callback from homeassistant.loader import bind_hass from homeassistant.helpers.storage import Store -from .const import DOMAIN, STEP_USER, STEPS, STEP_INTEGRATION +from .const import ( + DOMAIN, STEP_USER, STEPS, STEP_INTEGRATION, STEP_CORE_CONFIG) STORAGE_KEY = DOMAIN -STORAGE_VERSION = 2 +STORAGE_VERSION = 3 class OnboadingStorage(Store): @@ -15,7 +16,10 @@ class OnboadingStorage(Store): async def _async_migrate_func(self, old_version, old_data): """Migrate to the new version.""" # From version 1 -> 2, we automatically mark the integration step done - old_data['done'].append(STEP_INTEGRATION) + if old_version < 2: + old_data['done'].append(STEP_INTEGRATION) + if old_version < 3: + old_data['done'].append(STEP_CORE_CONFIG) return old_data diff --git a/homeassistant/components/onboarding/const.py b/homeassistant/components/onboarding/const.py index fe1b28fc316..bdc573efcb4 100644 --- a/homeassistant/components/onboarding/const.py +++ b/homeassistant/components/onboarding/const.py @@ -1,10 +1,12 @@ """Constants for the onboarding component.""" DOMAIN = 'onboarding' STEP_USER = 'user' +STEP_CORE_CONFIG = 'core_config' STEP_INTEGRATION = 'integration' STEPS = [ STEP_USER, + STEP_CORE_CONFIG, STEP_INTEGRATION, ] diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index a156fe4676f..c8060891fd4 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -7,13 +7,16 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import callback -from .const import DOMAIN, STEP_USER, STEPS, DEFAULT_AREAS, STEP_INTEGRATION +from .const import ( + DOMAIN, STEP_USER, STEPS, DEFAULT_AREAS, STEP_INTEGRATION, + STEP_CORE_CONFIG) async def async_setup(hass, data, store): """Set up the onboarding view.""" hass.http.register_view(OnboardingView(data, store)) hass.http.register_view(UserOnboardingView(data, store)) + hass.http.register_view(CoreConfigOnboardingView(data, store)) hass.http.register_view(IntegrationOnboardingView(data, store)) @@ -128,6 +131,26 @@ class UserOnboardingView(_BaseOnboardingView): }) +class CoreConfigOnboardingView(_BaseOnboardingView): + """View to finish core config onboarding step.""" + + url = '/api/onboarding/core_config' + name = 'api:onboarding:core_config' + step = STEP_CORE_CONFIG + + async def post(self, request): + """Handle finishing core config step.""" + hass = request.app['hass'] + + async with self._lock: + if self._async_is_done(): + return self.json_message('Core config step already done', 403) + + await self._async_mark_done(hass) + + return self.json({}) + + class IntegrationOnboardingView(_BaseOnboardingView): """View to finish integration onboarding step.""" @@ -139,7 +162,7 @@ class IntegrationOnboardingView(_BaseOnboardingView): vol.Required('client_id'): str, })) async def post(self, request, data): - """Handle user creation, area creation.""" + """Handle token creation.""" hass = request.app['hass'] user = request['hass_user'] diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 68c3c819567..230aa913791 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -308,7 +308,7 @@ class ONVIFHassCamera(Camera): image = await asyncio.shield(ffmpeg.get_image( self._input, output_format=IMAGE_JPEG, - extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop) + extra_cmd=self._ffmpeg_arguments)) return image async def handle_async_mjpeg_stream(self, request): @@ -339,8 +339,7 @@ class ONVIFHassCamera(Camera): return SUPPORT_STREAM return 0 - @property - def stream_source(self): + async def stream_source(self): """Return the stream source.""" return self._input diff --git a/homeassistant/components/openalpr_cloud/image_processing.py b/homeassistant/components/openalpr_cloud/image_processing.py index 12146009fac..78707d2f0a2 100644 --- a/homeassistant/components/openalpr_cloud/image_processing.py +++ b/homeassistant/components/openalpr_cloud/image_processing.py @@ -108,7 +108,7 @@ class OpenAlprCloudEntity(ImageProcessingAlprEntity): } try: - with async_timeout.timeout(self.timeout, loop=self.hass.loop): + with async_timeout.timeout(self.timeout): request = await websession.post( OPENALPR_API_URL, params=params, data=body ) diff --git a/homeassistant/components/opentherm_gw/manifest.json b/homeassistant/components/opentherm_gw/manifest.json index 50bfa4d1122..560e30931a3 100644 --- a/homeassistant/components/opentherm_gw/manifest.json +++ b/homeassistant/components/opentherm_gw/manifest.json @@ -1,9 +1,9 @@ { "domain": "opentherm_gw", - "name": "Opentherm gw", + "name": "Opentherm Gateway", "documentation": "https://www.home-assistant.io/components/opentherm_gw", "requirements": [ - "pyotgw==0.4b3" + "pyotgw==0.4b4" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/openuv/manifest.json b/homeassistant/components/openuv/manifest.json index b94a409aa71..0cfb02e81d6 100644 --- a/homeassistant/components/openuv/manifest.json +++ b/homeassistant/components/openuv/manifest.json @@ -1,6 +1,7 @@ { "domain": "openuv", "name": "Openuv", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/openuv", "requirements": [ "pyopenuv==1.0.9" diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index e746cbc01fa..1cc7a050aec 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -15,6 +15,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.setup import async_when_setup from .config_flow import CONF_SECRET +from .messages import async_handle_message _LOGGER = logging.getLogger(__name__) @@ -50,7 +51,9 @@ CONFIG_SCHEMA = vol.Schema({ async def async_setup(hass, config): """Initialize OwnTracks component.""" hass.data[DOMAIN] = { - 'config': config[DOMAIN] + 'config': config[DOMAIN], + 'devices': {}, + 'unsub': None, } if not hass.config_entries.async_entries(DOMAIN): hass.async_create_task(hass.config_entries.flow.async_init( @@ -88,9 +91,33 @@ async def async_setup_entry(hass, entry): hass.async_create_task(hass.config_entries.async_forward_entry_setup( entry, 'device_tracker')) + hass.data[DOMAIN]['unsub'] = \ + hass.helpers.dispatcher.async_dispatcher_connect( + DOMAIN, async_handle_message) + return True +async def async_unload_entry(hass, entry): + """Unload an OwnTracks config entry.""" + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + await hass.config_entries.async_forward_entry_unload( + entry, 'device_tracker') + hass.data[DOMAIN]['unsub']() + + return True + + +async def async_remove_entry(hass, entry): + """Remove an OwnTracks config entry.""" + if (not entry.data.get('cloudhook') or + 'cloud' not in hass.config.components): + return + + await hass.components.cloud.async_delete_cloudhook( + entry.data[CONF_WEBHOOK_ID]) + + async def async_connect_mqtt(hass, component): """Subscribe to MQTT topic.""" context = hass.data[DOMAIN]['context'] @@ -165,6 +192,7 @@ class OwnTracksContext: self.region_mapping = region_mapping self.events_only = events_only self.mqtt_topic = mqtt_topic + self._pending_msg = [] @callback def async_valid_accuracy(self, message): @@ -195,11 +223,22 @@ class OwnTracksContext: return True - async def async_see(self, **data): - """Send a see message to the device tracker.""" - raise NotImplementedError + @callback + def set_async_see(self, func): + """Set a new async_see function.""" + self.async_see = func + for msg in self._pending_msg: + func(**msg) + self._pending_msg.clear() - async def async_see_beacons(self, hass, dev_id, kwargs_param): + # pylint: disable=method-hidden + @callback + def async_see(self, **data): + """Send a see message to the device tracker.""" + self._pending_msg.append(data) + + @callback + def async_see_beacons(self, hass, dev_id, kwargs_param): """Set active beacons to the current location.""" kwargs = kwargs_param.copy() @@ -213,8 +252,13 @@ class OwnTracksContext: acc = device_tracker_state.attributes.get("gps_accuracy") lat = device_tracker_state.attributes.get("latitude") lon = device_tracker_state.attributes.get("longitude") - kwargs['gps_accuracy'] = acc - kwargs['gps'] = (lat, lon) + + if lat is not None and lon is not None: + kwargs['gps'] = (lat, lon) + kwargs['gps_accuracy'] = acc + else: + kwargs['gps'] = None + kwargs['gps_accuracy'] = None # the battery state applies to the tracking device, not the beacon # kwargs location is the beacon's configured lat/lon @@ -222,4 +266,4 @@ class OwnTracksContext: for beacon in self.mobile_beacons_active[dev_id]: kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) kwargs['host_name'] = beacon - await self.async_see(**kwargs) + self.async_see(**kwargs) diff --git a/homeassistant/components/owntracks/config_flow.py b/homeassistant/components/owntracks/config_flow.py index 59e8c4825df..f157c5cb7ce 100644 --- a/homeassistant/components/owntracks/config_flow.py +++ b/homeassistant/components/owntracks/config_flow.py @@ -4,6 +4,7 @@ from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.auth.util import generate_secret CONF_SECRET = 'secret' +CONF_CLOUDHOOK = 'cloudhook' def supports_encryption(): @@ -31,9 +32,7 @@ class OwnTracksFlow(config_entries.ConfigFlow): step_id='user', ) - webhook_id = self.hass.components.webhook.async_generate_id() - webhook_url = \ - self.hass.components.webhook.async_generate_url(webhook_id) + webhook_id, webhook_url, cloudhook = await self._get_webhook_id() secret = generate_secret(16) @@ -50,7 +49,8 @@ class OwnTracksFlow(config_entries.ConfigFlow): title="OwnTracks", data={ CONF_WEBHOOK_ID: webhook_id, - CONF_SECRET: secret + CONF_SECRET: secret, + CONF_CLOUDHOOK: cloudhook, }, description_placeholders={ 'secret': secret_desc, @@ -67,12 +67,29 @@ class OwnTracksFlow(config_entries.ConfigFlow): async def async_step_import(self, user_input): """Import a config flow from configuration.""" - webhook_id = self.hass.components.webhook.async_generate_id() + webhook_id, _webhook_url, cloudhook = await self._get_webhook_id() secret = generate_secret(16) return self.async_create_entry( title="OwnTracks", data={ CONF_WEBHOOK_ID: webhook_id, - CONF_SECRET: secret + CONF_SECRET: secret, + CONF_CLOUDHOOK: cloudhook, } ) + + async def _get_webhook_id(self): + """Generate webhook ID.""" + webhook_id = self.hass.components.webhook.async_generate_id() + if self.hass.components.cloud.async_active_subscription(): + webhook_url = \ + await self.hass.components.cloud.async_create_cloudhook( + webhook_id + ) + cloudhook = True + else: + webhook_url = \ + self.hass.components.webhook.async_generate_url(webhook_id) + cloudhook = False + + return webhook_id, webhook_url, cloudhook diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 999e883be19..742b7c34435 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -1,351 +1,166 @@ """Device tracker platform that adds support for OwnTracks over MQTT.""" -import json import logging -from homeassistant.components import zone as zone_comp -from homeassistant.components.device_tracker import ( - ATTR_SOURCE_TYPE, SOURCE_TYPE_BLUETOOTH_LE, SOURCE_TYPE_GPS) -from homeassistant.const import STATE_HOME -from homeassistant.util import decorator, slugify - +from homeassistant.core import callback +from homeassistant.const import ( + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_BATTERY_LEVEL, +) +from homeassistant.components.device_tracker.const import ( + ENTITY_ID_FORMAT, ATTR_SOURCE_TYPE) +from homeassistant.components.device_tracker.config_entry import ( + DeviceTrackerEntity +) +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers import device_registry from . import DOMAIN as OT_DOMAIN _LOGGER = logging.getLogger(__name__) -HANDLERS = decorator.Registry() - -async def async_setup_entry(hass, entry, async_see): +async def async_setup_entry(hass, entry, async_add_entities): """Set up OwnTracks based off an entry.""" - hass.data[OT_DOMAIN]['context'].async_see = async_see - hass.helpers.dispatcher.async_dispatcher_connect( - OT_DOMAIN, async_handle_message) + @callback + def _receive_data(dev_id, **data): + """Receive set location.""" + entity = hass.data[OT_DOMAIN]['devices'].get(dev_id) + + if entity is not None: + entity.update_data(data) + return + + entity = hass.data[OT_DOMAIN]['devices'][dev_id] = OwnTracksEntity( + dev_id, data + ) + async_add_entities([entity]) + + hass.data[OT_DOMAIN]['context'].set_async_see(_receive_data) + + # Restore previously loaded devices + dev_reg = await device_registry.async_get_registry(hass) + dev_ids = { + identifier[1] + for device in dev_reg.devices.values() + for identifier in device.identifiers + if identifier[0] == OT_DOMAIN + } + + if not dev_ids: + return True + + entities = [] + for dev_id in dev_ids: + entity = hass.data[OT_DOMAIN]['devices'][dev_id] = OwnTracksEntity( + dev_id + ) + entities.append(entity) + + async_add_entities(entities) + return True -def get_cipher(): - """Return decryption function and length of key. +class OwnTracksEntity(DeviceTrackerEntity, RestoreEntity): + """Represent a tracked device.""" - Async friendly. - """ - from nacl.secret import SecretBox - from nacl.encoding import Base64Encoder + def __init__(self, dev_id, data=None): + """Set up OwnTracks entity.""" + self._dev_id = dev_id + self._data = data or {} + self.entity_id = ENTITY_ID_FORMAT.format(dev_id) - def decrypt(ciphertext, key): - """Decrypt ciphertext using key.""" - return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder) - return (SecretBox.KEY_SIZE, decrypt) + @property + def unique_id(self): + """Return the unique ID.""" + return self._dev_id + @property + def battery_level(self): + """Return the battery level of the device.""" + return self._data.get('battery') -def _parse_topic(topic, subscribe_topic): - """Parse an MQTT topic {sub_topic}/user/dev, return (user, dev) tuple. + @property + def device_state_attributes(self): + """Return device specific attributes.""" + return self._data.get('attributes') - Async friendly. - """ - subscription = subscribe_topic.split('/') - try: - user_index = subscription.index('#') - except ValueError: - _LOGGER.error("Can't parse subscription topic: '%s'", subscribe_topic) - raise + @property + def location_accuracy(self): + """Return the gps accuracy of the device.""" + return self._data.get('gps_accuracy') - topic_list = topic.split('/') - try: - user, device = topic_list[user_index], topic_list[user_index + 1] - except IndexError: - _LOGGER.error("Can't parse topic: '%s'", topic) - raise + @property + def latitude(self): + """Return latitude value of the device.""" + # Check with "get" instead of "in" because value can be None + if self._data.get('gps'): + return self._data['gps'][0] - return user, device - - -def _parse_see_args(message, subscribe_topic): - """Parse the OwnTracks location parameters, into the format see expects. - - Async friendly. - """ - user, device = _parse_topic(message['topic'], subscribe_topic) - dev_id = slugify('{}_{}'.format(user, device)) - kwargs = { - 'dev_id': dev_id, - 'host_name': user, - 'gps': (message['lat'], message['lon']), - 'attributes': {} - } - if 'acc' in message: - kwargs['gps_accuracy'] = message['acc'] - if 'batt' in message: - kwargs['battery'] = message['batt'] - if 'vel' in message: - kwargs['attributes']['velocity'] = message['vel'] - if 'tid' in message: - kwargs['attributes']['tid'] = message['tid'] - if 'addr' in message: - kwargs['attributes']['address'] = message['addr'] - if 'cog' in message: - kwargs['attributes']['course'] = message['cog'] - if 't' in message: - if message['t'] == 'c': - kwargs['attributes'][ATTR_SOURCE_TYPE] = SOURCE_TYPE_GPS - if message['t'] == 'b': - kwargs['attributes'][ATTR_SOURCE_TYPE] = SOURCE_TYPE_BLUETOOTH_LE - - return dev_id, kwargs - - -def _set_gps_from_zone(kwargs, location, zone): - """Set the see parameters from the zone parameters. - - Async friendly. - """ - if zone is not None: - kwargs['gps'] = ( - zone.attributes['latitude'], - zone.attributes['longitude']) - kwargs['gps_accuracy'] = zone.attributes['radius'] - kwargs['location_name'] = location - return kwargs - - -def _decrypt_payload(secret, topic, ciphertext): - """Decrypt encrypted payload.""" - try: - keylen, decrypt = get_cipher() - except OSError: - _LOGGER.warning( - "Ignoring encrypted payload because libsodium not installed") return None - if isinstance(secret, dict): - key = secret.get(topic) - else: - key = secret + @property + def longitude(self): + """Return longitude value of the device.""" + # Check with "get" instead of "in" because value can be None + if self._data.get('gps'): + return self._data['gps'][1] - if key is None: - _LOGGER.warning( - "Ignoring encrypted payload because no decryption key known " - "for topic %s", topic) return None - key = key.encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b'\0') + @property + def location_name(self): + """Return a location name for the current location of the device.""" + return self._data.get('location_name') - try: - message = decrypt(ciphertext, key) - message = message.decode("utf-8") - _LOGGER.debug("Decrypted payload: %s", message) - return message - except ValueError: - _LOGGER.warning( - "Ignoring encrypted payload because unable to decrypt using " - "key for topic %s", topic) - return None + @property + def name(self): + """Return the name of the device.""" + return self._data.get('host_name') + @property + def should_poll(self): + """No polling needed.""" + return False -@HANDLERS.register('location') -async def async_handle_location_message(hass, context, message): - """Handle a location message.""" - if not context.async_valid_accuracy(message): - return + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return self._data.get('source_type') - if context.events_only: - _LOGGER.debug("Location update ignored due to events_only setting") - return + @property + def device_info(self): + """Return the device info.""" + return { + 'name': self.name, + 'identifiers': {(OT_DOMAIN, self._dev_id)}, + } - dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) + async def async_added_to_hass(self): + """Call when entity about to be added to Home Assistant.""" + await super().async_added_to_hass() - if context.regions_entered[dev_id]: - _LOGGER.debug( - "Location update ignored, inside region %s", - context.regions_entered[-1]) - return - - await context.async_see(**kwargs) - await context.async_see_beacons(hass, dev_id, kwargs) - - -async def _async_transition_message_enter(hass, context, message, location): - """Execute enter event.""" - zone = hass.states.get("zone.{}".format(slugify(location))) - dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) - - if zone is None and message.get('t') == 'b': - # Not a HA zone, and a beacon so mobile beacon. - # kwargs will contain the lat/lon of the beacon - # which is not where the beacon actually is - # and is probably set to 0/0 - beacons = context.mobile_beacons_active[dev_id] - if location not in beacons: - beacons.add(location) - _LOGGER.info("Added beacon %s", location) - await context.async_see_beacons(hass, dev_id, kwargs) - else: - # Normal region - regions = context.regions_entered[dev_id] - if location not in regions: - regions.append(location) - _LOGGER.info("Enter region %s", location) - _set_gps_from_zone(kwargs, location, zone) - await context.async_see(**kwargs) - await context.async_see_beacons(hass, dev_id, kwargs) - - -async def _async_transition_message_leave(hass, context, message, location): - """Execute leave event.""" - dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) - regions = context.regions_entered[dev_id] - - if location in regions: - regions.remove(location) - - beacons = context.mobile_beacons_active[dev_id] - if location in beacons: - beacons.remove(location) - _LOGGER.info("Remove beacon %s", location) - await context.async_see_beacons(hass, dev_id, kwargs) - else: - new_region = regions[-1] if regions else None - if new_region: - # Exit to previous region - zone = hass.states.get( - "zone.{}".format(slugify(new_region))) - _set_gps_from_zone(kwargs, new_region, zone) - _LOGGER.info("Exit to %s", new_region) - await context.async_see(**kwargs) - await context.async_see_beacons(hass, dev_id, kwargs) + # Don't restore if we got set up with data. + if self._data: return - _LOGGER.info("Exit to GPS") + state = await self.async_get_last_state() - # Check for GPS accuracy - if context.async_valid_accuracy(message): - await context.async_see(**kwargs) - await context.async_see_beacons(hass, dev_id, kwargs) - - -@HANDLERS.register('transition') -async def async_handle_transition_message(hass, context, message): - """Handle a transition message.""" - if message.get('desc') is None: - _LOGGER.error( - "Location missing from `Entering/Leaving` message - " - "please turn `Share` on in OwnTracks app") - return - # OwnTracks uses - at the start of a beacon zone - # to switch on 'hold mode' - ignore this - location = message['desc'].lstrip("-") - - # Create a layer of indirection for Owntracks instances that may name - # regions differently than their HA names - if location in context.region_mapping: - location = context.region_mapping[location] - - if location.lower() == 'home': - location = STATE_HOME - - if message['event'] == 'enter': - await _async_transition_message_enter( - hass, context, message, location) - elif message['event'] == 'leave': - await _async_transition_message_leave( - hass, context, message, location) - else: - _LOGGER.error( - "Misformatted mqtt msgs, _type=transition, event=%s", - message['event']) - - -async def async_handle_waypoint(hass, name_base, waypoint): - """Handle a waypoint.""" - name = waypoint['desc'] - pretty_name = '{} - {}'.format(name_base, name) - lat = waypoint['lat'] - lon = waypoint['lon'] - rad = waypoint['rad'] - - # check zone exists - entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) - - # Check if state already exists - if hass.states.get(entity_id) is not None: - return - - zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, - zone_comp.ICON_IMPORT, False) - zone.entity_id = entity_id - await zone.async_update_ha_state() - - -@HANDLERS.register('waypoint') -@HANDLERS.register('waypoints') -async def async_handle_waypoints_message(hass, context, message): - """Handle a waypoints message.""" - if not context.import_waypoints: - return - - if context.waypoint_whitelist is not None: - user = _parse_topic(message['topic'], context.mqtt_topic)[0] - - if user not in context.waypoint_whitelist: + if state is None: return - if 'waypoints' in message: - wayps = message['waypoints'] - else: - wayps = [message] + attr = state.attributes + self._data = { + 'host_name': state.name, + 'gps': (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)), + 'gps_accuracy': attr.get(ATTR_GPS_ACCURACY), + 'battery': attr.get(ATTR_BATTERY_LEVEL), + 'source_type': attr.get(ATTR_SOURCE_TYPE), + } - _LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic']) - - name_base = ' '.join(_parse_topic(message['topic'], context.mqtt_topic)) - - for wayp in wayps: - await async_handle_waypoint(hass, name_base, wayp) - - -@HANDLERS.register('encrypted') -async def async_handle_encrypted_message(hass, context, message): - """Handle an encrypted message.""" - if 'topic' not in message and isinstance(context.secret, dict): - _LOGGER.error("You cannot set per topic secrets when using HTTP") - return - - plaintext_payload = _decrypt_payload(context.secret, message.get('topic'), - message['data']) - - if plaintext_payload is None: - return - - decrypted = json.loads(plaintext_payload) - if 'topic' in message and 'topic' not in decrypted: - decrypted['topic'] = message['topic'] - - await async_handle_message(hass, context, decrypted) - - -@HANDLERS.register('lwt') -@HANDLERS.register('configuration') -@HANDLERS.register('beacon') -@HANDLERS.register('cmd') -@HANDLERS.register('steps') -@HANDLERS.register('card') -async def async_handle_not_impl_msg(hass, context, message): - """Handle valid but not implemented message types.""" - _LOGGER.debug('Not handling %s message: %s', message.get("_type"), message) - - -async def async_handle_unsupported_msg(hass, context, message): - """Handle an unsupported or invalid message type.""" - _LOGGER.warning('Received unsupported message type: %s.', - message.get('_type')) - - -async def async_handle_message(hass, context, message): - """Handle an OwnTracks message.""" - msgtype = message.get('_type') - - _LOGGER.debug("Received %s", message) - - handler = HANDLERS.get(msgtype, async_handle_unsupported_msg) - - await handler(hass, context, message) + @callback + def update_data(self, data): + """Mark the device as seen.""" + self._data = data + self.async_write_ha_state() diff --git a/homeassistant/components/owntracks/manifest.json b/homeassistant/components/owntracks/manifest.json index 60bce1bca3d..bc4fe97bc7f 100644 --- a/homeassistant/components/owntracks/manifest.json +++ b/homeassistant/components/owntracks/manifest.json @@ -1,6 +1,7 @@ { "domain": "owntracks", "name": "Owntracks", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/owntracks", "requirements": [ "PyNaCl==1.3.0" diff --git a/homeassistant/components/owntracks/messages.py b/homeassistant/components/owntracks/messages.py new file mode 100644 index 00000000000..7eac2148013 --- /dev/null +++ b/homeassistant/components/owntracks/messages.py @@ -0,0 +1,348 @@ +"""OwnTracks Message handlers.""" +import json +import logging + +from homeassistant.components import zone as zone_comp +from homeassistant.components.device_tracker import ( + SOURCE_TYPE_GPS, SOURCE_TYPE_BLUETOOTH_LE +) + +from homeassistant.const import STATE_HOME +from homeassistant.util import decorator, slugify + + +_LOGGER = logging.getLogger(__name__) + +HANDLERS = decorator.Registry() + + +def get_cipher(): + """Return decryption function and length of key. + + Async friendly. + """ + from nacl.secret import SecretBox + from nacl.encoding import Base64Encoder + + def decrypt(ciphertext, key): + """Decrypt ciphertext using key.""" + return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder) + return (SecretBox.KEY_SIZE, decrypt) + + +def _parse_topic(topic, subscribe_topic): + """Parse an MQTT topic {sub_topic}/user/dev, return (user, dev) tuple. + + Async friendly. + """ + subscription = subscribe_topic.split('/') + try: + user_index = subscription.index('#') + except ValueError: + _LOGGER.error("Can't parse subscription topic: '%s'", subscribe_topic) + raise + + topic_list = topic.split('/') + try: + user, device = topic_list[user_index], topic_list[user_index + 1] + except IndexError: + _LOGGER.error("Can't parse topic: '%s'", topic) + raise + + return user, device + + +def _parse_see_args(message, subscribe_topic): + """Parse the OwnTracks location parameters, into the format see expects. + + Async friendly. + """ + user, device = _parse_topic(message['topic'], subscribe_topic) + dev_id = slugify('{}_{}'.format(user, device)) + kwargs = { + 'dev_id': dev_id, + 'host_name': user, + 'attributes': {} + } + if message['lat'] is not None and message['lon'] is not None: + kwargs['gps'] = (message['lat'], message['lon']) + else: + kwargs['gps'] = None + + if 'acc' in message: + kwargs['gps_accuracy'] = message['acc'] + if 'batt' in message: + kwargs['battery'] = message['batt'] + if 'vel' in message: + kwargs['attributes']['velocity'] = message['vel'] + if 'tid' in message: + kwargs['attributes']['tid'] = message['tid'] + if 'addr' in message: + kwargs['attributes']['address'] = message['addr'] + if 'cog' in message: + kwargs['attributes']['course'] = message['cog'] + if 't' in message: + if message['t'] in ('c', 'u'): + kwargs['source_type'] = SOURCE_TYPE_GPS + if message['t'] == 'b': + kwargs['source_type'] = SOURCE_TYPE_BLUETOOTH_LE + + return dev_id, kwargs + + +def _set_gps_from_zone(kwargs, location, zone): + """Set the see parameters from the zone parameters. + + Async friendly. + """ + if zone is not None: + kwargs['gps'] = ( + zone.attributes['latitude'], + zone.attributes['longitude']) + kwargs['gps_accuracy'] = zone.attributes['radius'] + kwargs['location_name'] = location + return kwargs + + +def _decrypt_payload(secret, topic, ciphertext): + """Decrypt encrypted payload.""" + try: + keylen, decrypt = get_cipher() + except OSError: + _LOGGER.warning( + "Ignoring encrypted payload because libsodium not installed") + return None + + if isinstance(secret, dict): + key = secret.get(topic) + else: + key = secret + + if key is None: + _LOGGER.warning( + "Ignoring encrypted payload because no decryption key known " + "for topic %s", topic) + return None + + key = key.encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + try: + message = decrypt(ciphertext, key) + message = message.decode("utf-8") + _LOGGER.debug("Decrypted payload: %s", message) + return message + except ValueError: + _LOGGER.warning( + "Ignoring encrypted payload because unable to decrypt using " + "key for topic %s", topic) + return None + + +@HANDLERS.register('location') +async def async_handle_location_message(hass, context, message): + """Handle a location message.""" + if not context.async_valid_accuracy(message): + return + + if context.events_only: + _LOGGER.debug("Location update ignored due to events_only setting") + return + + dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) + + if context.regions_entered[dev_id]: + _LOGGER.debug( + "Location update ignored, inside region %s", + context.regions_entered[-1]) + return + + context.async_see(**kwargs) + context.async_see_beacons(hass, dev_id, kwargs) + + +async def _async_transition_message_enter(hass, context, message, location): + """Execute enter event.""" + zone = hass.states.get("zone.{}".format(slugify(location))) + dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) + + if zone is None and message.get('t') == 'b': + # Not a HA zone, and a beacon so mobile beacon. + # kwargs will contain the lat/lon of the beacon + # which is not where the beacon actually is + # and is probably set to 0/0 + beacons = context.mobile_beacons_active[dev_id] + if location not in beacons: + beacons.add(location) + _LOGGER.info("Added beacon %s", location) + context.async_see_beacons(hass, dev_id, kwargs) + else: + # Normal region + regions = context.regions_entered[dev_id] + if location not in regions: + regions.append(location) + _LOGGER.info("Enter region %s", location) + _set_gps_from_zone(kwargs, location, zone) + context.async_see(**kwargs) + context.async_see_beacons(hass, dev_id, kwargs) + + +async def _async_transition_message_leave(hass, context, message, location): + """Execute leave event.""" + dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) + regions = context.regions_entered[dev_id] + + if location in regions: + regions.remove(location) + + beacons = context.mobile_beacons_active[dev_id] + if location in beacons: + beacons.remove(location) + _LOGGER.info("Remove beacon %s", location) + context.async_see_beacons(hass, dev_id, kwargs) + else: + new_region = regions[-1] if regions else None + if new_region: + # Exit to previous region + zone = hass.states.get( + "zone.{}".format(slugify(new_region))) + _set_gps_from_zone(kwargs, new_region, zone) + _LOGGER.info("Exit to %s", new_region) + context.async_see(**kwargs) + context.async_see_beacons(hass, dev_id, kwargs) + return + + _LOGGER.info("Exit to GPS") + + # Check for GPS accuracy + if context.async_valid_accuracy(message): + context.async_see(**kwargs) + context.async_see_beacons(hass, dev_id, kwargs) + + +@HANDLERS.register('transition') +async def async_handle_transition_message(hass, context, message): + """Handle a transition message.""" + if message.get('desc') is None: + _LOGGER.error( + "Location missing from `Entering/Leaving` message - " + "please turn `Share` on in OwnTracks app") + return + # OwnTracks uses - at the start of a beacon zone + # to switch on 'hold mode' - ignore this + location = message['desc'].lstrip("-") + + # Create a layer of indirection for Owntracks instances that may name + # regions differently than their HA names + if location in context.region_mapping: + location = context.region_mapping[location] + + if location.lower() == 'home': + location = STATE_HOME + + if message['event'] == 'enter': + await _async_transition_message_enter( + hass, context, message, location) + elif message['event'] == 'leave': + await _async_transition_message_leave( + hass, context, message, location) + else: + _LOGGER.error( + "Misformatted mqtt msgs, _type=transition, event=%s", + message['event']) + + +async def async_handle_waypoint(hass, name_base, waypoint): + """Handle a waypoint.""" + name = waypoint['desc'] + pretty_name = '{} - {}'.format(name_base, name) + lat = waypoint['lat'] + lon = waypoint['lon'] + rad = waypoint['rad'] + + # check zone exists + entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) + + # Check if state already exists + if hass.states.get(entity_id) is not None: + return + + zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, + zone_comp.ICON_IMPORT, False) + zone.entity_id = entity_id + await zone.async_update_ha_state() + + +@HANDLERS.register('waypoint') +@HANDLERS.register('waypoints') +async def async_handle_waypoints_message(hass, context, message): + """Handle a waypoints message.""" + if not context.import_waypoints: + return + + if context.waypoint_whitelist is not None: + user = _parse_topic(message['topic'], context.mqtt_topic)[0] + + if user not in context.waypoint_whitelist: + return + + if 'waypoints' in message: + wayps = message['waypoints'] + else: + wayps = [message] + + _LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic']) + + name_base = ' '.join(_parse_topic(message['topic'], context.mqtt_topic)) + + for wayp in wayps: + await async_handle_waypoint(hass, name_base, wayp) + + +@HANDLERS.register('encrypted') +async def async_handle_encrypted_message(hass, context, message): + """Handle an encrypted message.""" + if 'topic' not in message and isinstance(context.secret, dict): + _LOGGER.error("You cannot set per topic secrets when using HTTP") + return + + plaintext_payload = _decrypt_payload(context.secret, message.get('topic'), + message['data']) + + if plaintext_payload is None: + return + + decrypted = json.loads(plaintext_payload) + if 'topic' in message and 'topic' not in decrypted: + decrypted['topic'] = message['topic'] + + await async_handle_message(hass, context, decrypted) + + +@HANDLERS.register('lwt') +@HANDLERS.register('configuration') +@HANDLERS.register('beacon') +@HANDLERS.register('cmd') +@HANDLERS.register('steps') +@HANDLERS.register('card') +async def async_handle_not_impl_msg(hass, context, message): + """Handle valid but not implemented message types.""" + _LOGGER.debug('Not handling %s message: %s', message.get("_type"), message) + + +async def async_handle_unsupported_msg(hass, context, message): + """Handle an unsupported or invalid message type.""" + _LOGGER.warning('Received unsupported message type: %s.', + message.get('_type')) + + +async def async_handle_message(hass, context, message): + """Handle an OwnTracks message.""" + msgtype = message.get('_type') + + _LOGGER.debug("Received %s", message) + + handler = HANDLERS.get(msgtype, async_handle_unsupported_msg) + + await handler(hass, context, message) diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py index f6a4fcdb733..275d80facf4 100644 --- a/homeassistant/components/panel_custom/__init__.py +++ b/homeassistant/components/panel_custom/__init__.py @@ -112,7 +112,7 @@ async def async_register_panel( config['_panel_custom'] = custom_panel_config - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( component_name='custom', sidebar_title=sidebar_title, sidebar_icon=sidebar_icon, diff --git a/homeassistant/components/panel_custom/manifest.json b/homeassistant/components/panel_custom/manifest.json index 5fb7adb2a4a..06c9338742c 100644 --- a/homeassistant/components/panel_custom/manifest.json +++ b/homeassistant/components/panel_custom/manifest.json @@ -7,6 +7,6 @@ "frontend" ], "codeowners": [ - "@home-assistant/core" + "@home-assistant/frontend" ] } diff --git a/homeassistant/components/panel_iframe/__init__.py b/homeassistant/components/panel_iframe/__init__.py index f4038c82f71..fca33b1cf98 100644 --- a/homeassistant/components/panel_iframe/__init__.py +++ b/homeassistant/components/panel_iframe/__init__.py @@ -32,7 +32,7 @@ CONFIG_SCHEMA = vol.Schema({ async def async_setup(hass, config): """Set up the iFrame frontend panels.""" for url_path, info in config[DOMAIN].items(): - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON), url_path, {'url': info[CONF_URL]}, require_admin=info[CONF_REQUIRE_ADMIN]) diff --git a/homeassistant/components/panel_iframe/manifest.json b/homeassistant/components/panel_iframe/manifest.json index 127ff3caa4d..e66f94bdcc2 100644 --- a/homeassistant/components/panel_iframe/manifest.json +++ b/homeassistant/components/panel_iframe/manifest.json @@ -7,6 +7,6 @@ "frontend" ], "codeowners": [ - "@home-assistant/core" + "@home-assistant/frontend" ] } diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 9f9bf4475b4..6cbb2147aa9 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -8,8 +8,9 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, - SOURCE_TYPE_ROUTER) + PLATFORM_SCHEMA) +from homeassistant.components.device_tracker.const import ( + CONF_SCAN_INTERVAL, SCAN_INTERVAL, SOURCE_TYPE_ROUTER) from homeassistant import util from homeassistant import const @@ -68,7 +69,7 @@ def setup_scanner(hass, config, see, discovery_info=None): interval = config.get(CONF_SCAN_INTERVAL, timedelta(seconds=len(hosts) * config[CONF_PING_COUNT]) - + DEFAULT_SCAN_INTERVAL) + + SCAN_INTERVAL) _LOGGER.debug("Started ping tracker with interval=%s on hosts: %s", interval, ",".join([host.ip_address for host in hosts])) diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 4a65808e049..5ce375ffe03 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -6,7 +6,6 @@ import logging import requests import voluptuous as vol -from homeassistant import util from homeassistant.components.media_player import ( MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.components.media_player.const import ( @@ -16,30 +15,24 @@ from homeassistant.components.media_player.const import ( from homeassistant.const import ( DEVICE_DEFAULT_NAME, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import track_utc_time_change +from homeassistant.helpers.event import track_time_interval from homeassistant.util import dt as dt_util from homeassistant.util.json import load_json, save_json _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) - +NAME_FORMAT = 'Plex {}' PLEX_CONFIG_FILE = 'plex.conf' PLEX_DATA = 'plex' -CONF_INCLUDE_NON_CLIENTS = 'include_non_clients' CONF_USE_EPISODE_ART = 'use_episode_art' -CONF_USE_CUSTOM_ENTITY_IDS = 'use_custom_entity_ids' CONF_SHOW_ALL_CONTROLS = 'show_all_controls' CONF_REMOVE_UNAVAILABLE_CLIENTS = 'remove_unavailable_clients' CONF_CLIENT_REMOVE_INTERVAL = 'client_remove_interval' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_INCLUDE_NON_CLIENTS, default=False): cv.boolean, vol.Optional(CONF_USE_EPISODE_ART, default=False): cv.boolean, - vol.Optional(CONF_USE_CUSTOM_ENTITY_IDS, default=False): cv.boolean, vol.Optional(CONF_SHOW_ALL_CONTROLS, default=False): cv.boolean, vol.Optional(CONF_REMOVE_UNAVAILABLE_CLIENTS, default=True): cv.boolean, vol.Optional(CONF_CLIENT_REMOVE_INTERVAL, default=timedelta(seconds=600)): @@ -134,9 +127,9 @@ def setup_plexserver( plex_clients = hass.data[PLEX_DATA] plex_sessions = {} - track_utc_time_change(hass, lambda now: update_devices(), second=30) + track_time_interval( + hass, lambda now: update_devices(), timedelta(seconds=10)) - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) def update_devices(): """Update the devices objects.""" try: @@ -160,8 +153,7 @@ def setup_plexserver( if device.machineIdentifier not in plex_clients: new_client = PlexClient( - config, device, None, plex_sessions, update_devices, - update_sessions) + config, device, None, plex_sessions, update_devices) plex_clients[device.machineIdentifier] = new_client _LOGGER.debug("New device: %s", device.machineIdentifier) new_plex_clients.append(new_client) @@ -171,57 +163,6 @@ def setup_plexserver( plex_clients[device.machineIdentifier].refresh(device, None) # add devices with a session and no client (ex. PlexConnect Apple TV's) - if config.get(CONF_INCLUDE_NON_CLIENTS): - # To avoid errors when plex sessions created during iteration - sessions = list(plex_sessions.items()) - for machine_identifier, (session, player) in sessions: - if machine_identifier in available_client_ids: - # Avoid using session if already added as a device. - _LOGGER.debug("Skipping session, device exists: %s", - machine_identifier) - continue - - if (machine_identifier not in plex_clients - and machine_identifier is not None): - new_client = PlexClient( - config, player, session, plex_sessions, update_devices, - update_sessions) - plex_clients[machine_identifier] = new_client - _LOGGER.debug("New session: %s", machine_identifier) - new_plex_clients.append(new_client) - else: - _LOGGER.debug("Refreshing session: %s", machine_identifier) - plex_clients[machine_identifier].refresh(None, session) - - clients_to_remove = [] - for client in plex_clients.values(): - # force devices to idle that do not have a valid session - if client.session is None: - client.force_idle() - - client.set_availability(client.machine_identifier - in available_client_ids - or client.machine_identifier - in plex_sessions) - - if not config.get(CONF_REMOVE_UNAVAILABLE_CLIENTS) \ - or client.available: - continue - - if (dt_util.utcnow() - client.marked_unavailable) >= \ - (config.get(CONF_CLIENT_REMOVE_INTERVAL)): - hass.add_job(client.async_remove()) - clients_to_remove.append(client.machine_identifier) - - while clients_to_remove: - del plex_clients[clients_to_remove.pop()] - - if new_plex_clients: - add_entities_callback(new_plex_clients) - - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def update_sessions(): - """Update the sessions objects.""" try: sessions = plexserver.sessions() except plexapi.exceptions.BadRequest: @@ -237,8 +178,52 @@ def setup_plexserver( for player in session.players: plex_sessions[player.machineIdentifier] = session, player - update_sessions() - update_devices() + for machine_identifier, (session, player) in plex_sessions.items(): + if machine_identifier in available_client_ids: + # Avoid using session if already added as a device. + _LOGGER.debug("Skipping session, device exists: %s", + machine_identifier) + continue + + if (machine_identifier not in plex_clients + and machine_identifier is not None): + new_client = PlexClient( + config, player, session, plex_sessions, update_devices) + plex_clients[machine_identifier] = new_client + _LOGGER.debug("New session: %s", machine_identifier) + new_plex_clients.append(new_client) + else: + _LOGGER.debug("Refreshing session: %s", machine_identifier) + plex_clients[machine_identifier].refresh(None, session) + + clients_to_remove = [] + for client in plex_clients.values(): + # force devices to idle that do not have a valid session + if client.session is None: + client.force_idle() + + client.set_availability(client.machine_identifier + in available_client_ids + or client.machine_identifier + in plex_sessions) + + if client not in new_plex_clients: + client.schedule_update_ha_state() + + if not config.get(CONF_REMOVE_UNAVAILABLE_CLIENTS) \ + or client.available: + continue + + if (dt_util.utcnow() - client.marked_unavailable) >= \ + (config.get(CONF_CLIENT_REMOVE_INTERVAL)): + hass.add_job(client.async_remove()) + clients_to_remove.append(client.machine_identifier) + + while clients_to_remove: + del plex_clients[clients_to_remove.pop()] + + if new_plex_clients: + add_entities_callback(new_plex_clients) def request_configuration(host, hass, config, add_entities_callback): @@ -285,7 +270,7 @@ class PlexClient(MediaPlayerDevice): """Representation of a Plex device.""" def __init__(self, config, device, session, plex_sessions, - update_devices, update_sessions): + update_devices): """Initialize the Plex device.""" self._app_name = '' self._device = None @@ -309,7 +294,6 @@ class PlexClient(MediaPlayerDevice): self.config = config self.plex_sessions = plex_sessions self.update_devices = update_devices - self.update_sessions = update_sessions # General self._media_content_id = None self._media_content_rating = None @@ -331,24 +315,6 @@ class PlexClient(MediaPlayerDevice): self.refresh(device, session) - # Assign custom entity ID if desired - if self.config.get(CONF_USE_CUSTOM_ENTITY_IDS): - prefix = '' - # allow for namespace prefixing when using custom entity names - if config.get("entity_namespace"): - prefix = config.get("entity_namespace") + '_' - - # rename the entity id - if self.machine_identifier: - self.entity_id = "%s.%s%s" % ( - 'media_player', prefix, - self.machine_identifier.lower().replace('-', '_')) - else: - if self.name: - self.entity_id = "%s.%s%s" % ( - 'media_player', prefix, - self.name.lower().replace('-', '_')) - def _clear_media_details(self): """Set all Media Items to None.""" # General @@ -390,7 +356,8 @@ class PlexClient(MediaPlayerDevice): self._device.proxyThroughServer() self._session = None self._machine_identifier = self._device.machineIdentifier - self._name = self._device.title or DEVICE_DEFAULT_NAME + self._name = NAME_FORMAT.format(self._device.title or + DEVICE_DEFAULT_NAME) self._device_protocol_capabilities = ( self._device.protocolCapabilities) @@ -407,7 +374,7 @@ class PlexClient(MediaPlayerDevice): self._player = [p for p in self._session.players if p.machineIdentifier == self._device.machineIdentifier][0] - self._name = self._player.title + self._name = NAME_FORMAT.format(self._player.title) self._player_state = self._player.state self._session_username = self._session.usernames[0] self._make = self._player.device @@ -527,6 +494,11 @@ class PlexClient(MediaPlayerDevice): self._session = None self._clear_media_details() + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return False + @property def unique_id(self): """Return the id of this plex client.""" @@ -572,11 +544,6 @@ class PlexClient(MediaPlayerDevice): """Return the state of the device.""" return self._state - def update(self): - """Get the latest details.""" - self.update_devices(no_throttle=True) - self.update_sessions(no_throttle=True) - @property def _active_media_plexapi_type(self): """Get the active media type required by PlexAPI commands.""" @@ -719,6 +686,7 @@ class PlexClient(MediaPlayerDevice): self.device.setVolume( int(volume * 100), self._active_media_plexapi_type) self._volume_level = volume # store since we can't retrieve + self.update_devices() @property def volume_level(self): @@ -755,16 +723,19 @@ class PlexClient(MediaPlayerDevice): """Send play command.""" if self.device and 'playback' in self._device_protocol_capabilities: self.device.play(self._active_media_plexapi_type) + self.update_devices() def media_pause(self): """Send pause command.""" if self.device and 'playback' in self._device_protocol_capabilities: self.device.pause(self._active_media_plexapi_type) + self.update_devices() def media_stop(self): """Send stop command.""" if self.device and 'playback' in self._device_protocol_capabilities: self.device.stop(self._active_media_plexapi_type) + self.update_devices() def turn_off(self): """Turn the client off.""" @@ -775,11 +746,13 @@ class PlexClient(MediaPlayerDevice): """Send next track command.""" if self.device and 'playback' in self._device_protocol_capabilities: self.device.skipNext(self._active_media_plexapi_type) + self.update_devices() def media_previous_track(self): """Send previous track command.""" if self.device and 'playback' in self._device_protocol_capabilities: self.device.skipPrevious(self._active_media_plexapi_type) + self.update_devices() def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" @@ -883,6 +856,7 @@ class PlexClient(MediaPlayerDevice): '/playQueues/{}?window=100&own=1'.format( playqueue.playQueueID), }, **params)) + self.update_devices() @property def device_state_attributes(self): diff --git a/homeassistant/components/point/.translations/no.json b/homeassistant/components/point/.translations/no.json index c5e4a7b2e86..58b6e1e63fd 100644 --- a/homeassistant/components/point/.translations/no.json +++ b/homeassistant/components/point/.translations/no.json @@ -4,7 +4,7 @@ "already_setup": "Du kan kun konfigurere \u00e9n Point-konto.", "authorize_url_fail": "Ukjent feil ved generering en autoriseringsadresse.", "authorize_url_timeout": "Tidsavbrudd ved generering av autoriseringsadresse.", - "external_setup": "Point vellykket konfigurasjon fra en annen flow.", + "external_setup": "Punktet er konfigurert fra en annen flyt.", "no_flows": "Du m\u00e5 konfigurere Point f\u00f8r du kan autentisere med den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/point/)." }, "create_entry": { diff --git a/homeassistant/components/point/.translations/pl.json b/homeassistant/components/point/.translations/pl.json index 98fa79573b0..66b454e47ff 100644 --- a/homeassistant/components/point/.translations/pl.json +++ b/homeassistant/components/point/.translations/pl.json @@ -5,7 +5,7 @@ "authorize_url_fail": "Nieznany b\u0142\u0105d podczas generowania url autoryzacji.", "authorize_url_timeout": "Min\u0105\u0142 limit czasu generowania url autoryzacji.", "external_setup": "Punkt pomy\u015blnie skonfigurowany.", - "no_flows": "Musisz skonfigurowa\u0107 Point, zanim b\u0119dziesz m\u00f3g\u0142 si\u0119 z nim uwierzytelni\u0107. [Przeczytaj instrukcje](https://www.home-assistant.io/components/point/)." + "no_flows": "Musisz skonfigurowa\u0107 Point, zanim b\u0119dziesz m\u00f3g\u0142 si\u0119 z nim uwierzytelni\u0107. [Przeczytaj instrukcj\u0119](https://www.home-assistant.io/components/point/)." }, "create_entry": { "default": "Pomy\u015blnie uwierzytelniono przy u\u017cyciu Minut dla urz\u0105dze\u0144 Point" diff --git a/homeassistant/components/point/manifest.json b/homeassistant/components/point/manifest.json index 8b888a3647a..fcc9265ce9b 100644 --- a/homeassistant/components/point/manifest.json +++ b/homeassistant/components/point/manifest.json @@ -1,6 +1,7 @@ { "domain": "point", "name": "Point", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/point", "requirements": [ "pypoint==1.1.1" diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py index 1f2067cc660..7511a89370c 100644 --- a/homeassistant/components/prowl/notify.py +++ b/homeassistant/components/prowl/notify.py @@ -52,7 +52,7 @@ class ProwlNotificationService(BaseNotificationService): session = async_get_clientsession(self._hass) try: - with async_timeout.timeout(10, loop=self._hass.loop): + with async_timeout.timeout(10): response = await session.post(url, data=payload) result = await response.text() diff --git a/homeassistant/components/ps4/.translations/es.json b/homeassistant/components/ps4/.translations/es.json index fd68e06a552..d2d749e4deb 100644 --- a/homeassistant/components/ps4/.translations/es.json +++ b/homeassistant/components/ps4/.translations/es.json @@ -8,6 +8,7 @@ "port_997_bind_error": "No se ha podido unir al puerto 997. Consulta la [documentaci\u00f3n](https://www.home-assistant.io/components/ps4/) para m\u00e1s informaci\u00f3n." }, "error": { + "credential_timeout": "Se agot\u00f3 el tiempo para el servicio de credenciales. Pulsa enviar para reiniciar.", "login_failed": "No se ha podido emparejar con PlayStation 4. Verifique que el PIN sea correcto.", "no_ipaddress": "Introduce la direcci\u00f3n IP de la PlayStation 4 que quieres configurar.", "not_ready": "PlayStation 4 no est\u00e1 encendido o conectado a la red." diff --git a/homeassistant/components/ps4/.translations/fr.json b/homeassistant/components/ps4/.translations/fr.json index cfd65c910d9..03baf0c032e 100644 --- a/homeassistant/components/ps4/.translations/fr.json +++ b/homeassistant/components/ps4/.translations/fr.json @@ -8,6 +8,7 @@ "port_997_bind_error": "Impossible de se connecter au port 997." }, "error": { + "credential_timeout": "Le service d'informations d'identification a expir\u00e9. Appuyez sur soumettre pour red\u00e9marrer.", "login_failed": "\u00c9chec de l'association \u00e0 la PlayStation 4. V\u00e9rifiez que le code PIN est correct.", "no_ipaddress": "Entrez l'adresse IP de la PlayStation 4 que vous souhaitez configurer.", "not_ready": "PlayStation 4 n'est pas allum\u00e9e ou connect\u00e9e au r\u00e9seau." diff --git a/homeassistant/components/ps4/.translations/ko.json b/homeassistant/components/ps4/.translations/ko.json index 51454eeb135..f13a66d5e8a 100644 --- a/homeassistant/components/ps4/.translations/ko.json +++ b/homeassistant/components/ps4/.translations/ko.json @@ -15,7 +15,7 @@ }, "step": { "creds": { - "description": "\uc790\uaca9 \uc99d\uba85\uc774 \ud544\uc694\ud569\ub2c8\ub2e4. 'Submit' \uc744 \ub204\ub978 \ub2e4\uc74c PS4 Second Screen \uc571\uc5d0\uc11c \uae30\uae30\ub97c \uc0c8\ub85c \uace0\uce68\ud558\uace0 'Home-Assistant' \uae30\uae30\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.", + "description": "\uc790\uaca9 \uc99d\uba85\uc774 \ud544\uc694\ud569\ub2c8\ub2e4. 'Submit' \uc744 \ub204\ub978 \ub2e4\uc74c PS4 \uc138\ucee8\ub4dc \uc2a4\ud06c\ub9b0 \uc571\uc5d0\uc11c \uae30\uae30\ub97c \uc0c8\ub85c \uace0\uce68\ud558\uace0 'Home-Assistant' \uae30\uae30\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.", "title": "PlayStation 4" }, "link": { diff --git a/homeassistant/components/ps4/.translations/nl.json b/homeassistant/components/ps4/.translations/nl.json index 3dcadef20eb..c3cdf03355f 100644 --- a/homeassistant/components/ps4/.translations/nl.json +++ b/homeassistant/components/ps4/.translations/nl.json @@ -9,6 +9,7 @@ }, "error": { "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." }, "step": { @@ -25,6 +26,12 @@ }, "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.", "title": "PlayStation 4" + }, + "mode": { + "data": { + "ip_address": "IP-adres (leeg laten als u Auto Discovery gebruikt)." + }, + "title": "PlayStation 4" } }, "title": "PlayStation 4" diff --git a/homeassistant/components/ps4/.translations/sl.json b/homeassistant/components/ps4/.translations/sl.json index 429a409fb7e..f51bc45e0e8 100644 --- a/homeassistant/components/ps4/.translations/sl.json +++ b/homeassistant/components/ps4/.translations/sl.json @@ -8,6 +8,7 @@ "port_997_bind_error": "Ne morem se povezati z vrati 997. Dodatne informacije najdete v [dokumentaciji] (https://www.home-assistant.io/components/ps4/)." }, "error": { + "credential_timeout": "Storitev poverilnic je potekla. Pritisnite Po\u0161lji za ponovni zagon.", "login_failed": "Neuspelo seznanjanje s PlayStation 4. Preverite, ali je koda PIN pravilna.", "no_ipaddress": "Vnesite IP naslov PlayStation-a 4, ki ga \u017eelite konfigurirati.", "not_ready": "PlayStation 4 ni vklopljen ali povezan z omre\u017ejem." diff --git a/homeassistant/components/ps4/.translations/sv.json b/homeassistant/components/ps4/.translations/sv.json index 81f24179e54..a36c8e28d9e 100644 --- a/homeassistant/components/ps4/.translations/sv.json +++ b/homeassistant/components/ps4/.translations/sv.json @@ -8,6 +8,7 @@ "port_997_bind_error": "Kunde inte binda till port 997." }, "error": { + "credential_timeout": "Autentiseringstj\u00e4nsten orsakade timeout. Tryck p\u00e5 Skicka f\u00f6r att starta om.", "login_failed": "Misslyckades med att para till PlayStation 4. Verifiera PIN-koden \u00e4r korrekt.", "no_ipaddress": "Ange IP-adressen f\u00f6r PlayStation 4 du vill konfigurera.", "not_ready": "PlayStation 4 \u00e4r inte p\u00e5slagen eller ansluten till n\u00e4tverket." @@ -32,6 +33,7 @@ "ip_address": "IP-adress (l\u00e4mna tom om du anv\u00e4nder automatisk uppt\u00e4ckt).", "mode": "Konfigureringsl\u00e4ge" }, + "description": "V\u00e4lj l\u00e4ge f\u00f6r konfigurering. F\u00e4ltet IP-adress kan l\u00e4mnas tomt om du v\u00e4ljer Automatisk uppt\u00e4ckt, eftersom enheter d\u00e5 kommer att identifieras automatiskt.", "title": "PlayStation 4" } }, diff --git a/homeassistant/components/ps4/manifest.json b/homeassistant/components/ps4/manifest.json index 087f1618378..1cf613bf9b9 100644 --- a/homeassistant/components/ps4/manifest.json +++ b/homeassistant/components/ps4/manifest.json @@ -1,6 +1,7 @@ { "domain": "ps4", "name": "Ps4", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/ps4", "requirements": [ "pyps4-homeassistant==0.7.3" diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index c962aee91ca..ccef0e72cda 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -60,7 +60,7 @@ async def async_setup_platform(hass, config, async_add_entities, async def handle_webhook(hass, webhook_id, request): """Handle incoming webhook POST with image files.""" try: - with async_timeout.timeout(5, loop=hass.loop): + with async_timeout.timeout(5): data = dict(await request.post()) except (asyncio.TimeoutError, aiohttp.web.HTTPException) as error: _LOGGER.error("Could not get information from POST <%s>", error) diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index ad7bdada321..b99798bb4b6 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -1,6 +1,7 @@ { "domain": "rainmachine", "name": "Rainmachine", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/rainmachine", "requirements": [ "regenmaschine==1.4.0" diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 97654b21c6d..528f6f4a8a3 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -141,7 +141,7 @@ class Recorder(threading.Thread): self.queue = queue.Queue() # type: Any self.recording_start = dt_util.utcnow() self.db_url = uri - self.async_db_ready = asyncio.Future(loop=hass.loop) + self.async_db_ready = asyncio.Future() self.engine = None # type: Any self.run_info = None # type: Any diff --git a/homeassistant/components/recswitch/switch.py b/homeassistant/components/recswitch/switch.py index c43064c5674..90b0bd0b218 100644 --- a/homeassistant/components/recswitch/switch.py +++ b/homeassistant/components/recswitch/switch.py @@ -33,7 +33,7 @@ async def async_setup_platform(hass, config, async_add_entities, if not hass.data.get(DATA_RSN): hass.data[DATA_RSN] = RSNetwork() - job = hass.data[DATA_RSN].create_datagram_endpoint(loop=hass.loop) + job = hass.data[DATA_RSN].create_datagram_endpoint() hass.async_create_task(job) device = hass.data[DATA_RSN].register_device(mac_address, host) diff --git a/homeassistant/components/remote_rpi_gpio/__init__.py b/homeassistant/components/remote_rpi_gpio/__init__.py new file mode 100644 index 00000000000..82865b00cda --- /dev/null +++ b/homeassistant/components/remote_rpi_gpio/__init__.py @@ -0,0 +1,63 @@ +"""Support for controlling GPIO pins of a Raspberry Pi.""" +import logging + +_LOGGER = logging.getLogger(__name__) + +CONF_BOUNCETIME = 'bouncetime' +CONF_INVERT_LOGIC = 'invert_logic' +CONF_PULL_MODE = 'pull_mode' + +DEFAULT_BOUNCETIME = 50 +DEFAULT_INVERT_LOGIC = False +DEFAULT_PULL_MODE = "UP" + +DOMAIN = 'remote_rpi_gpio' + + +def setup(hass, config): + """Set up the Raspberry Pi Remote GPIO component.""" + return True + + +def setup_output(address, port, invert_logic): + """Set up a GPIO as output.""" + from gpiozero import LED + from gpiozero.pins.pigpio import PiGPIOFactory + + try: + return LED(port, active_high=invert_logic, + pin_factory=PiGPIOFactory(address)) + except (ValueError, IndexError, KeyError): + return None + + +def setup_input(address, port, pull_mode, bouncetime): + """Set up a GPIO as input.""" + from gpiozero import Button + from gpiozero.pins.pigpio import PiGPIOFactory + + if pull_mode == "UP": + pull_gpio_up = True + elif pull_mode == "DOWN": + pull_gpio_up = False + + try: + return Button(port, + pull_up=pull_gpio_up, + bounce_time=bouncetime, + pin_factory=PiGPIOFactory(address)) + except (ValueError, IndexError, KeyError, IOError): + return None + + +def write_output(switch, value): + """Write a value to a GPIO.""" + if value == 1: + switch.on() + if value == 0: + switch.off() + + +def read_input(button): + """Read a value from a GPIO.""" + return button.is_pressed diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py new file mode 100644 index 00000000000..4c359163e56 --- /dev/null +++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py @@ -0,0 +1,106 @@ +"""Support for binary sensor using RPi GPIO.""" +import logging + +import voluptuous as vol + +import requests + +from homeassistant.const import CONF_HOST +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) + +import homeassistant.helpers.config_validation as cv + +from . import (CONF_BOUNCETIME, CONF_PULL_MODE, CONF_INVERT_LOGIC, + DEFAULT_BOUNCETIME, DEFAULT_INVERT_LOGIC, DEFAULT_PULL_MODE) +from .. import remote_rpi_gpio + +_LOGGER = logging.getLogger(__name__) + +CONF_PORTS = 'ports' + +_SENSORS_SCHEMA = vol.Schema({ + cv.positive_int: cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORTS): _SENSORS_SCHEMA, + vol.Optional(CONF_INVERT_LOGIC, + default=DEFAULT_INVERT_LOGIC): cv.boolean, + vol.Optional(CONF_BOUNCETIME, + default=DEFAULT_BOUNCETIME): cv.positive_int, + vol.Optional(CONF_PULL_MODE, + default=DEFAULT_PULL_MODE): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Raspberry PI GPIO devices.""" + address = config['host'] + invert_logic = config[CONF_INVERT_LOGIC] + pull_mode = config[CONF_PULL_MODE] + ports = config['ports'] + bouncetime = config[CONF_BOUNCETIME]/1000 + + devices = [] + for port_num, port_name in ports.items(): + try: + button = remote_rpi_gpio.setup_input(address, + port_num, + pull_mode, + bouncetime) + except (ValueError, IndexError, KeyError, IOError): + return + new_sensor = RemoteRPiGPIOBinarySensor(port_name, button, invert_logic) + devices.append(new_sensor) + + add_entities(devices, True) + + +class RemoteRPiGPIOBinarySensor(BinarySensorDevice): + """Represent a binary sensor that uses a Remote Raspberry Pi GPIO.""" + + def __init__(self, name, button, invert_logic): + """Initialize the RPi binary sensor.""" + self._name = name + self._invert_logic = invert_logic + self._state = False + self._button = button + + async def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + def read_gpio(): + """Read state from GPIO.""" + self._state = remote_rpi_gpio.read_input(self._button) + self.schedule_update_ha_state() + + self._button.when_released = read_gpio + self._button.when_pressed = read_gpio + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the entity.""" + return self._state != self._invert_logic + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return + + def update(self): + """Update the GPIO state.""" + try: + self._state = remote_rpi_gpio.read_input(self._button) + except requests.exceptions.ConnectionError: + return diff --git a/homeassistant/components/remote_rpi_gpio/manifest.json b/homeassistant/components/remote_rpi_gpio/manifest.json new file mode 100644 index 00000000000..f15defd63dc --- /dev/null +++ b/homeassistant/components/remote_rpi_gpio/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "remote_rpi_gpio", + "name": "remote_rpi_gpio", + "documentation": "https://www.home-assistant.io/components/remote_rpi_gpio", + "requirements": [ + "gpiozero==1.4.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py new file mode 100644 index 00000000000..493ccf03c32 --- /dev/null +++ b/homeassistant/components/remote_rpi_gpio/switch.py @@ -0,0 +1,91 @@ +"""Allows to configure a switch using RPi GPIO.""" +import logging + +import voluptuous as vol + +from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA +from homeassistant.const import DEVICE_DEFAULT_NAME, CONF_HOST + +import homeassistant.helpers.config_validation as cv + +from . import CONF_INVERT_LOGIC, DEFAULT_INVERT_LOGIC +from .. import remote_rpi_gpio + +_LOGGER = logging.getLogger(__name__) + +CONF_PORTS = 'ports' + +_SENSORS_SCHEMA = vol.Schema({ + cv.positive_int: cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORTS): _SENSORS_SCHEMA, + vol.Optional(CONF_INVERT_LOGIC, + default=DEFAULT_INVERT_LOGIC): cv.boolean +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Remote Raspberry PI GPIO devices.""" + address = config[CONF_HOST] + invert_logic = config[CONF_INVERT_LOGIC] + ports = config[CONF_PORTS] + + devices = [] + for port, name in ports.items(): + try: + led = remote_rpi_gpio.setup_output( + address, port, invert_logic) + except (ValueError, IndexError, KeyError, IOError): + return + new_switch = RemoteRPiGPIOSwitch(name, led, invert_logic) + devices.append(new_switch) + + add_entities(devices) + + +class RemoteRPiGPIOSwitch(SwitchDevice): + """Representation of a Remtoe Raspberry Pi GPIO.""" + + def __init__(self, name, led, invert_logic): + """Initialize the pin.""" + self._name = name or DEVICE_DEFAULT_NAME + self._state = False + self._invert_logic = invert_logic + self._switch = led + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def assumed_state(self): + """If unable to access real state of the entity.""" + return True + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the device on.""" + remote_rpi_gpio.write_output(self._switch, + 0 if self._invert_logic else 1) + self._state = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn the device off.""" + remote_rpi_gpio.write_output(self._switch, + 1 if self._invert_logic else 0) + self._state = False + self.schedule_update_ha_state() diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py new file mode 100755 index 00000000000..24382b2f12d --- /dev/null +++ b/homeassistant/components/repetier/__init__.py @@ -0,0 +1,248 @@ +"""Support for Repetier-Server sensors.""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import ( + CONF_API_KEY, CONF_HOST, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_PORT, + CONF_SENSORS, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.event import track_time_interval +from homeassistant.util import slugify as util_slugify + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'RepetierServer' +DOMAIN = 'repetier' +REPETIER_API = 'repetier_api' +SCAN_INTERVAL = timedelta(seconds=10) +UPDATE_SIGNAL = 'repetier_update_signal' + +TEMP_DATA = { + 'tempset': 'temp_set', + 'tempread': 'state', + 'output': 'output', +} + + +API_PRINTER_METHODS = { + 'bed_temperature': { + 'offline': {'heatedbeds': None, 'state': 'off'}, + 'state': {'heatedbeds': 'temp_data'}, + 'temp_data': TEMP_DATA, + 'attribute': 'heatedbeds', + }, + 'extruder_temperature': { + 'offline': {'extruder': None, 'state': 'off'}, + 'state': {'extruder': 'temp_data'}, + 'temp_data': TEMP_DATA, + 'attribute': 'extruder', + }, + 'chamber_temperature': { + 'offline': {'heatedchambers': None, 'state': 'off'}, + 'state': {'heatedchambers': 'temp_data'}, + 'temp_data': TEMP_DATA, + 'attribute': 'heatedchambers', + }, + 'current_state': { + 'offline': {'state': None}, + 'state': { + 'state': 'state', + 'activeextruder': 'active_extruder', + 'hasxhome': 'x_homed', + 'hasyhome': 'y_homed', + 'haszhome': 'z_homed', + 'firmware': 'firmware', + 'firmwareurl': 'firmware_url', + }, + }, + 'current_job': { + 'offline': {'job': None, 'state': 'off'}, + 'state': { + 'done': 'state', + 'job': 'job_name', + 'jobid': 'job_id', + 'totallines': 'total_lines', + 'linessent': 'lines_sent', + 'oflayer': 'total_layers', + 'layer': 'current_layer', + 'speedmultiply': 'feed_rate', + 'flowmultiply': 'flow', + 'x': 'x', + 'y': 'y', + 'z': 'z', + }, + }, + 'job_end': { + 'offline': { + 'job': None, 'state': 'off', 'start': None, 'printtime': None}, + 'state': { + 'job': 'job_name', + 'start': 'start', + 'printtime': 'print_time', + 'printedtimecomp': 'from_start', + }, + }, + 'job_start': { + 'offline': { + 'job': None, + 'state': 'off', + 'start': None, + 'printedtimecomp': None + }, + 'state': { + 'job': 'job_name', + 'start': 'start', + 'printedtimecomp': 'from_start', + }, + }, +} + + +def has_all_unique_names(value): + """Validate that printers have an unique name.""" + names = [util_slugify(printer[CONF_NAME]) for printer in value] + vol.Schema(vol.Unique())(names) + return value + + +SENSOR_TYPES = { + # Type, Unit, Icon + 'bed_temperature': ['temperature', TEMP_CELSIUS, 'mdi:thermometer', + '_bed_'], + 'extruder_temperature': ['temperature', TEMP_CELSIUS, 'mdi:thermometer', + '_extruder_'], + 'chamber_temperature': ['temperature', TEMP_CELSIUS, 'mdi:thermometer', + '_chamber_'], + 'current_state': ['state', None, 'mdi:printer-3d', ''], + 'current_job': ['progress', '%', 'mdi:file-percent', '_current_job'], + 'job_end': ['progress', None, 'mdi:clock-end', '_job_end'], + 'job_start': ['progress', None, 'mdi:clock-start', '_job_start'], +} + +SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=3344): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + })], has_all_unique_names), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Repetier Server component.""" + import pyrepetier + + hass.data[REPETIER_API] = {} + + for repetier in config[DOMAIN]: + _LOGGER.debug("Repetier server config %s", repetier[CONF_HOST]) + + url = "http://{}".format(repetier[CONF_HOST]) + port = repetier[CONF_PORT] + api_key = repetier[CONF_API_KEY] + + client = pyrepetier.Repetier( + url=url, + port=port, + apikey=api_key) + + printers = client.getprinters() + + if not printers: + return False + + sensors = repetier[CONF_SENSORS][CONF_MONITORED_CONDITIONS] + api = PrinterAPI(hass, client, printers, sensors, + repetier[CONF_NAME], config) + api.update() + track_time_interval(hass, api.update, SCAN_INTERVAL) + + hass.data[REPETIER_API][repetier[CONF_NAME]] = api + + return True + + +class PrinterAPI: + """Handle the printer API.""" + + def __init__(self, hass, client, printers, sensors, conf_name, config): + """Set up instance.""" + self._hass = hass + self._client = client + self.printers = printers + self.sensors = sensors + self.conf_name = conf_name + self.config = config + self._known_entities = set() + + def get_data(self, printer_id, sensor_type, temp_id): + """Get data from the state cache.""" + printer = self.printers[printer_id] + methods = API_PRINTER_METHODS[sensor_type] + for prop, offline in methods['offline'].items(): + state = getattr(printer, prop) + if state == offline: + # if state matches offline, sensor is offline + return None + + data = {} + for prop, attr in methods['state'].items(): + prop_data = getattr(printer, prop) + if attr == 'temp_data': + temp_methods = methods['temp_data'] + for temp_prop, temp_attr in temp_methods.items(): + data[temp_attr] = getattr(prop_data[temp_id], temp_prop) + else: + data[attr] = prop_data + return data + + def update(self, now=None): + """Update the state cache from the printer API.""" + for printer in self.printers: + printer.get_data() + self._load_entities() + dispatcher_send(self._hass, UPDATE_SIGNAL) + + def _load_entities(self): + sensor_info = [] + for pidx, printer in enumerate(self.printers): + for sensor_type in self.sensors: + info = {} + info['sensor_type'] = sensor_type + info['printer_id'] = pidx + info['name'] = printer.slug + info['printer_name'] = self.conf_name + + known = '{}-{}'.format(printer.slug, sensor_type) + if known in self._known_entities: + continue + + methods = API_PRINTER_METHODS[sensor_type] + if 'temp_data' in methods['state'].values(): + prop_data = getattr(printer, methods['attribute']) + if prop_data is None: + continue + for idx, _ in enumerate(prop_data): + info['temp_id'] = idx + sensor_info.append(info) + else: + info['temp_id'] = None + sensor_info.append(info) + + self._known_entities.add(known) + + if not sensor_info: + return + load_platform(self._hass, 'sensor', DOMAIN, sensor_info, self.config) diff --git a/homeassistant/components/repetier/manifest.json b/homeassistant/components/repetier/manifest.json new file mode 100755 index 00000000000..14af98cfb64 --- /dev/null +++ b/homeassistant/components/repetier/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "repetier", + "name": "Repetier Server", + "documentation": "https://www.home-assistant.io/components/repetier", + "requirements": [ + "pyrepetier==3.0.5" + ], + "dependencies": [], + "codeowners": ["@MTrab"] +} diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py new file mode 100755 index 00000000000..17f999a95cf --- /dev/null +++ b/homeassistant/components/repetier/sensor.py @@ -0,0 +1,215 @@ +"""Support for monitoring Repetier Server Sensors.""" +from datetime import datetime +import logging +import time + +from homeassistant.const import DEVICE_CLASS_TIMESTAMP +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import REPETIER_API, SENSOR_TYPES, UPDATE_SIGNAL + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the available Repetier Server sensors.""" + if discovery_info is None: + return + + sensor_map = { + 'bed_temperature': RepetierTempSensor, + 'extruder_temperature': RepetierTempSensor, + 'chamber_temperature': RepetierTempSensor, + 'current_state': RepetierSensor, + 'current_job': RepetierJobSensor, + 'job_end': RepetierJobEndSensor, + 'job_start': RepetierJobStartSensor, + } + + entities = [] + for info in discovery_info: + printer_name = info['printer_name'] + api = hass.data[REPETIER_API][printer_name] + printer_id = info['printer_id'] + sensor_type = info['sensor_type'] + temp_id = info['temp_id'] + name = info['name'] + if temp_id is not None: + name = '{}{}{}'.format( + name, SENSOR_TYPES[sensor_type][3], temp_id) + else: + name = '{}{}'.format(name, SENSOR_TYPES[sensor_type][3]) + sensor_class = sensor_map[sensor_type] + entity = sensor_class(api, temp_id, name, printer_id, sensor_type) + entities.append(entity) + + add_entities(entities, True) + + +class RepetierSensor(Entity): + """Class to create and populate a Repetier Sensor.""" + + def __init__(self, api, temp_id, name, printer_id, sensor_type): + """Init new sensor.""" + self._api = api + self._attributes = {} + self._available = False + self._temp_id = temp_id + self._name = name + self._printer_id = printer_id + self._sensor_type = sensor_type + self._state = None + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def device_state_attributes(self): + """Return sensor attributes.""" + return self._attributes + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return SENSOR_TYPES[self._sensor_type][1] + + @property + def icon(self): + """Icon to use in the frontend.""" + return SENSOR_TYPES[self._sensor_type][2] + + @property + def should_poll(self): + """Return False as entity is updated from the component.""" + return False + + @property + def state(self): + """Return sensor state.""" + return self._state + + @callback + def update_callback(self): + """Get new data and update state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Connect update callbacks.""" + async_dispatcher_connect( + self.hass, UPDATE_SIGNAL, self.update_callback) + + def _get_data(self): + """Return new data from the api cache.""" + data = self._api.get_data( + self._printer_id, self._sensor_type, self._temp_id) + if data is None: + _LOGGER.debug( + "Data not found for %s and %s", + self._sensor_type, self._temp_id) + self._available = False + return None + self._available = True + return data + + def update(self): + """Update the sensor.""" + data = self._get_data() + if data is None: + return + state = data.pop('state') + _LOGGER.debug("Printer %s State %s", self._name, state) + self._attributes.update(data) + self._state = state + + +class RepetierTempSensor(RepetierSensor): + """Represent a Repetier temp sensor.""" + + @property + def state(self): + """Return sensor state.""" + if self._state is None: + return None + return round(self._state, 2) + + def update(self): + """Update the sensor.""" + data = self._get_data() + if data is None: + return + state = data.pop('state') + temp_set = data['temp_set'] + _LOGGER.debug( + "Printer %s Setpoint: %s, Temp: %s", + self._name, temp_set, state) + self._attributes.update(data) + self._state = state + + +class RepetierJobSensor(RepetierSensor): + """Represent a Repetier job sensor.""" + + @property + def state(self): + """Return sensor state.""" + if self._state is None: + return None + return round(self._state, 2) + + +class RepetierJobEndSensor(RepetierSensor): + """Class to create and populate a Repetier Job End timestamp Sensor.""" + + @property + def device_class(self): + """Return the device class.""" + return DEVICE_CLASS_TIMESTAMP + + def update(self): + """Update the sensor.""" + data = self._get_data() + if data is None: + return + job_name = data['job_name'] + start = data['start'] + print_time = data['print_time'] + from_start = data['from_start'] + time_end = start + round(print_time, 0) + self._state = datetime.utcfromtimestamp(time_end).isoformat() + remaining = print_time - from_start + remaining_secs = int(round(remaining, 0)) + _LOGGER.debug( + "Job %s remaining %s", + job_name, time.strftime('%H:%M:%S', time.gmtime(remaining_secs))) + + +class RepetierJobStartSensor(RepetierSensor): + """Class to create and populate a Repetier Job Start timestamp Sensor.""" + + @property + def device_class(self): + """Return the device class.""" + return DEVICE_CLASS_TIMESTAMP + + def update(self): + """Update the sensor.""" + data = self._get_data() + if data is None: + return + job_name = data['job_name'] + start = data['start'] + from_start = data['from_start'] + self._state = datetime.utcfromtimestamp(start).isoformat() + elapsed_secs = int(round(from_start, 0)) + _LOGGER.debug( + "Job %s elapsed %s", + job_name, time.strftime('%H:%M:%S', time.gmtime(elapsed_secs))) diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 2ef45b226fe..65a06908881 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -149,7 +149,7 @@ class RestSwitch(SwitchDevice): """Send a state update to the device.""" websession = async_get_clientsession(self.hass, self._verify_ssl) - with async_timeout.timeout(self._timeout, loop=self.hass.loop): + with async_timeout.timeout(self._timeout): req = await getattr(websession, self._method)( self._resource, auth=self._auth, data=bytes(body, 'utf-8'), headers=self._headers) @@ -168,7 +168,7 @@ class RestSwitch(SwitchDevice): """Get the latest data from REST API and update the state.""" websession = async_get_clientsession(hass, self._verify_ssl) - with async_timeout.timeout(self._timeout, loop=hass.loop): + with async_timeout.timeout(self._timeout): req = await websession.get(self._resource, auth=self._auth, headers=self._headers) text = await req.text() diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index 01c5d837ca9..a37e7f3e8ba 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -92,7 +92,7 @@ async def async_setup(hass, config): 'utf-8') try: - with async_timeout.timeout(timeout, loop=hass.loop): + with async_timeout.timeout(timeout): request = await getattr(websession, method)( template_url.async_render(variables=service.data), data=payload, diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index f7e42ce4357..3545d16ebbd 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -44,7 +44,24 @@ DATA_TYPES = OrderedDict([ ('Sound', ''), ('Sensor Status', ''), ('Counter value', ''), - ('UV', 'uv')]) + ('UV', 'uv'), + ('Humidity status', ''), + ('Forecast', ''), + ('Forecast numeric', ''), + ('Rain total', ''), + ('Wind average speed', ''), + ('Wind gust', ''), + ('Chill', ''), + ('Total usage', ''), + ('Count', ''), + ('Current Ch. 1', ''), + ('Current Ch. 2', ''), + ('Current Ch. 3', ''), + ('Energy usage', ''), + ('Voltage', ''), + ('Current', ''), + ('Battery numeric', ''), + ('Rssi numeric', '')]) RECEIVED_EVT_SUBSCRIBERS = [] RFX_DEVICES = {} diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 2a680a63b78..ddab2861539 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -116,7 +116,7 @@ class RingCam(Camera): image = await asyncio.shield(ffmpeg.get_image( self._video_url, output_format=IMAGE_JPEG, - extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop) + extra_cmd=self._ffmpeg_arguments)) return image async def handle_async_mjpeg_stream(self, request): diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index af81d9c031a..4667e9b8314 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -3,7 +3,7 @@ "name": "Russound rio", "documentation": "https://www.home-assistant.io/components/russound_rio", "requirements": [ - "russound_rio==0.1.4" + "russound_rio==0.1.7" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index 86392c0902d..f15e3ec61f0 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -70,7 +70,7 @@ async def async_setup(hass, config): tasks = [scene.async_activate() for scene in target_scenes] if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) hass.services.async_register( DOMAIN, SERVICE_TURN_ON, async_handle_scene_service, diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 528e454c4e6..36cb144fada 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -82,7 +82,7 @@ async def async_setup(hass, config): await asyncio.wait([ script.async_turn_off() for script in await component.async_extract_from_service(service) - ], loop=hass.loop) + ]) async def toggle_service(service): """Toggle a script.""" @@ -168,8 +168,14 @@ class ScriptEntity(ToggleEntity): ATTR_NAME: self.script.name, ATTR_ENTITY_ID: self.entity_id, }, context=context) - await self.script.async_run( - kwargs.get(ATTR_VARIABLES), context) + try: + await self.script.async_run( + kwargs.get(ATTR_VARIABLES), context) + except Exception as err: # pylint: disable=broad-except + self.script.async_log_exception( + _LOGGER, "Error executing script {}".format(self.entity_id), + err) + raise err async def async_turn_off(self, **kwargs): """Turn script off.""" diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index d2f95dcee79..0becbce5bca 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -101,7 +101,7 @@ async def async_setup_platform(hass, config, async_add_entities, update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - await asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks) hass.services.async_register( DOMAIN, SERVICE_ASSUME_STATE, async_assume_state, schema=ASSUME_STATE_SCHEMA) diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index cfcbfdd4224..6318d8581c3 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -117,7 +117,7 @@ def async_setup(hass, config): 'What is on my shopping list' ]) - yield from hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'shopping-list', 'shopping_list', 'mdi:cart') hass.components.websocket_api.async_register_command( diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index eac586b355d..b6bb1285daa 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -1,6 +1,7 @@ { "domain": "simplisafe", "name": "Simplisafe", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/simplisafe", "requirements": [ "simplisafe-python==3.4.1" diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 9b6406484df..9f33e236186 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -141,7 +141,7 @@ async def async_setup_platform( if task: tasks.append(task) if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) interval = config.get(CONF_SCAN_INTERVAL) or timedelta(seconds=5) async_track_time_interval(hass, async_sma, interval) diff --git a/homeassistant/components/smarthab/__init__.py b/homeassistant/components/smarthab/__init__.py new file mode 100644 index 00000000000..af592b60a91 --- /dev/null +++ b/homeassistant/components/smarthab/__init__.py @@ -0,0 +1,61 @@ +""" +Support for SmartHab device integration. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/smarthab/ +""" +import logging + +import voluptuous as vol + +from homeassistant.helpers.discovery import load_platform +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +import homeassistant.helpers.config_validation as cv + +DOMAIN = 'smarthab' +DATA_HUB = 'hub' + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config) -> bool: + """Set up the SmartHab platform.""" + import pysmarthab + + sh_conf = config.get(DOMAIN) + + # Assign configuration variables + username = sh_conf[CONF_EMAIL] + password = sh_conf[CONF_PASSWORD] + + # Setup connection with SmartHab API + hub = pysmarthab.SmartHab() + + try: + hub.login(username, password) + except pysmarthab.RequestFailedException as ex: + _LOGGER.error("Error while trying to reach SmartHab API.") + _LOGGER.debug(ex, exc_info=True) + return False + + # Verify that passed in configuration works + if not hub.is_logged_in(): + _LOGGER.error("Could not authenticate with SmartHab API") + return False + + # Pass hub object to child platforms + hass.data[DOMAIN] = { + DATA_HUB: hub + } + + load_platform(hass, 'light', DOMAIN, None, config) + load_platform(hass, 'cover', DOMAIN, None, config) + + return True diff --git a/homeassistant/components/smarthab/cover.py b/homeassistant/components/smarthab/cover.py new file mode 100644 index 00000000000..0b8cc0604e7 --- /dev/null +++ b/homeassistant/components/smarthab/cover.py @@ -0,0 +1,100 @@ +""" +Support for SmartHab device integration. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/smarthab/ +""" +import logging +from datetime import timedelta +from requests.exceptions import Timeout + +from homeassistant.components.cover import ( + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, + ATTR_POSITION +) +from . import DOMAIN, DATA_HUB + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=60) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the SmartHab roller shutters platform.""" + import pysmarthab + + hub = hass.data[DOMAIN][DATA_HUB] + devices = hub.get_device_list() + + _LOGGER.debug("Found a total of %s devices", str(len(devices))) + + entities = (SmartHabCover(cover) + for cover in devices if isinstance(cover, pysmarthab.Shutter)) + + add_entities(entities, True) + + +class SmartHabCover(CoverDevice): + """Representation a cover.""" + + def __init__(self, cover): + """Initialize a SmartHabCover.""" + self._cover = cover + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._cover.device_id + + @property + def name(self) -> str: + """Return the display name of this light.""" + return self._cover.label + + @property + def current_cover_position(self) -> int: + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._cover.state + + @property + def supported_features(self) -> int: + """Flag supported features.""" + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + + if self.current_cover_position is not None: + supported_features |= SUPPORT_SET_POSITION + + return supported_features + + @property + def is_closed(self) -> bool: + """Return if the cover is closed or not.""" + return self._cover.state == 0 + + @property + def device_class(self) -> str: + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'window' + + def open_cover(self, **kwargs): + """Open the cover.""" + self._cover.open() + + def close_cover(self, **kwargs): + """Close cover.""" + self._cover.close() + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + self._cover.state = kwargs[ATTR_POSITION] + + def update(self): + """Fetch new state data for this cover.""" + try: + self._cover.update() + except Timeout: + _LOGGER.error("Reached timeout while updating cover %s from API", + self.entity_id) diff --git a/homeassistant/components/smarthab/light.py b/homeassistant/components/smarthab/light.py new file mode 100644 index 00000000000..9be49912a49 --- /dev/null +++ b/homeassistant/components/smarthab/light.py @@ -0,0 +1,70 @@ +""" +Support for SmartHab device integration. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/smarthab/ +""" +import logging +from datetime import timedelta +from requests.exceptions import Timeout + +from homeassistant.components.light import Light +from . import DOMAIN, DATA_HUB + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=60) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the SmartHab lights platform.""" + import pysmarthab + + hub = hass.data[DOMAIN][DATA_HUB] + devices = hub.get_device_list() + + _LOGGER.debug("Found a total of %s devices", str(len(devices))) + + entities = (SmartHabLight(light) + for light in devices if isinstance(light, pysmarthab.Light)) + + add_entities(entities, True) + + +class SmartHabLight(Light): + """Representation of a SmartHab Light.""" + + def __init__(self, light): + """Initialize a SmartHabLight.""" + self._light = light + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._light.device_id + + @property + def name(self) -> str: + """Return the display name of this light.""" + return self._light.label + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self._light.state + + def turn_on(self, **kwargs): + """Instruct the light to turn on.""" + self._light.turn_on() + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + self._light.turn_off() + + def update(self): + """Fetch new state data for this light.""" + try: + self._light.update() + except Timeout: + _LOGGER.error("Reached timeout while updating light %s from API", + self.entity_id) diff --git a/homeassistant/components/smarthab/manifest.json b/homeassistant/components/smarthab/manifest.json new file mode 100644 index 00000000000..18b587bac92 --- /dev/null +++ b/homeassistant/components/smarthab/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "smarthab", + "name": "SmartHab", + "documentation": "https://www.home-assistant.io/components/smarthab", + "requirements": [ + "smarthab==0.20" + ], + "dependencies": [], + "codeowners": ["@outadoc"] +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index d31a90c6eb8..75b113354ff 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -1,6 +1,7 @@ { "domain": "smartthings", "name": "Smartthings", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/smartthings", "requirements": [ "pysmartapp==0.3.2", diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json index e4ad478e033..421eadca51c 100644 --- a/homeassistant/components/smhi/manifest.json +++ b/homeassistant/components/smhi/manifest.json @@ -1,6 +1,7 @@ { "domain": "smhi", "name": "Smhi", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/smhi", "requirements": [ "smhi-pkg==1.0.10" diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index ab5d08e770b..feeb22608a6 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -107,7 +107,7 @@ class SmhiWeather(WeatherEntity): RETRY_TIMEOUT, self.retry_update()) try: - with async_timeout.timeout(10, loop=self.hass.loop): + with async_timeout.timeout(10): self._forecasts = await self.get_weather_forecast() self._fail_count = 0 diff --git a/homeassistant/components/solax/__init__.py b/homeassistant/components/solax/__init__.py new file mode 100644 index 00000000000..3995ab10ac9 --- /dev/null +++ b/homeassistant/components/solax/__init__.py @@ -0,0 +1 @@ +"""The solax component.""" diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json new file mode 100644 index 00000000000..8e5f9d960f0 --- /dev/null +++ b/homeassistant/components/solax/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "solax", + "name": "Solax Inverter", + "documentation": "https://www.home-assistant.io/components/solax", + "requirements": [ + "solax==0.0.3" + ], + "dependencies": [], + "codeowners": ["@squishykid"] + } + \ No newline at end of file diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py new file mode 100644 index 00000000000..46d8722f831 --- /dev/null +++ b/homeassistant/components/solax/sensor.py @@ -0,0 +1,106 @@ +"""Support for Solax inverter via local API.""" +import asyncio + +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import ( + TEMP_CELSIUS, + CONF_IP_ADDRESS +) +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.event import async_track_time_interval + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_IP_ADDRESS): cv.string, +}) + +SCAN_INTERVAL = timedelta(seconds=30) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Platform setup.""" + import solax + + api = solax.solax.RealTimeAPI(config[CONF_IP_ADDRESS]) + endpoint = RealTimeDataEndpoint(hass, api) + hass.async_add_job(endpoint.async_refresh) + async_track_time_interval(hass, endpoint.async_refresh, SCAN_INTERVAL) + devices = [] + for sensor in solax.INVERTER_SENSORS: + unit = solax.INVERTER_SENSORS[sensor][1] + if unit == 'C': + unit = TEMP_CELSIUS + devices.append(Inverter(sensor, unit)) + endpoint.sensors = devices + async_add_entities(devices) + + +class RealTimeDataEndpoint: + """Representation of a Sensor.""" + + def __init__(self, hass, api): + """Initialize the sensor.""" + self.hass = hass + self.api = api + self.data = {} + self.ready = asyncio.Event() + self.sensors = [] + + async def async_refresh(self, now=None): + """Fetch new state data for the sensor. + + This is the only method that should fetch new data for Home Assistant. + """ + from solax import SolaxRequestError + + try: + self.data = await self.api.get_data() + self.ready.set() + except SolaxRequestError: + if now is not None: + self.ready.clear() + else: + raise PlatformNotReady + for sensor in self.sensors: + if sensor.key in self.data: + sensor.value = self.data[sensor.key] + sensor.async_schedule_update_ha_state() + + +class Inverter(Entity): + """Class for a sensor.""" + + def __init__(self, key, unit): + """Initialize an inverter sensor.""" + self.key = key + self.value = None + self.unit = unit + + @property + def state(self): + """State of this inverter attribute.""" + return self.value + + @property + def name(self): + """Name of this inverter attribute.""" + return self.key + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self.unit + + @property + def should_poll(self): + """No polling needed.""" + return False diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 5f7b2d04431..4d3df055bbf 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -5,10 +5,11 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.const import CONF_HOSTS, ATTR_ENTITY_ID, ATTR_TIME -from homeassistant.helpers import config_entry_flow, config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send -DOMAIN = 'sonos' +from .const import DOMAIN + CONF_ADVERTISE_ADDR = 'advertise_addr' CONF_INTERFACE_ADDR = 'interface_addr' @@ -141,14 +142,3 @@ async def async_setup_entry(hass, entry): hass.async_create_task(hass.config_entries.async_forward_entry_setup( entry, MP_DOMAIN)) return True - - -async def _async_has_devices(hass): - """Return if there are devices that can be discovered.""" - import pysonos - - return await hass.async_add_executor_job(pysonos.discover) - - -config_entry_flow.register_discovery_flow( - DOMAIN, 'Sonos', _async_has_devices, config_entries.CONN_CLASS_LOCAL_PUSH) diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py new file mode 100644 index 00000000000..ca3932a76c2 --- /dev/null +++ b/homeassistant/components/sonos/config_flow.py @@ -0,0 +1,15 @@ +"""Config flow for SONOS.""" +from homeassistant.helpers import config_entry_flow +from homeassistant import config_entries +from .const import DOMAIN + + +async def _async_has_devices(hass): + """Return if there are devices that can be discovered.""" + import pysonos + + return await hass.async_add_executor_job(pysonos.discover) + + +config_entry_flow.register_discovery_flow( + DOMAIN, 'Sonos', _async_has_devices, config_entries.CONN_CLASS_LOCAL_PUSH) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py new file mode 100644 index 00000000000..5858f2bca9b --- /dev/null +++ b/homeassistant/components/sonos/const.py @@ -0,0 +1,3 @@ +"""Const for Sonos.""" + +DOMAIN = "sonos" diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 5eac580313e..58fa7b49f88 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -1,6 +1,7 @@ { "domain": "sonos", "name": "Sonos", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/sonos", "requirements": [ "pysonos==0.0.12" diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 5d1cd138260..e8004ec8428 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -19,7 +19,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET) from homeassistant.const import ( - ENTITY_MATCH_ALL, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING) + ENTITY_MATCH_ALL, STATE_IDLE, STATE_PAUSED, STATE_PLAYING) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.dt import utcnow @@ -63,7 +63,7 @@ class SonosData: def __init__(self, hass): """Initialize the data.""" self.entities = [] - self.topology_condition = asyncio.Condition(loop=hass.loop) + self.topology_condition = asyncio.Condition() async def async_setup_platform(hass, @@ -118,6 +118,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): _discovered_player, interface_addr=config.get(CONF_INTERFACE_ADDR)) + for entity in hass.data[DATA_SONOS].entities: + entity.check_unseen() + hass.helpers.event.call_later(DISCOVERY_INTERVAL, _discovery) hass.async_add_executor_job(_discovery) @@ -307,8 +310,6 @@ class SonosEntity(MediaPlayerDevice): return STATE_PAUSED if self._status in ('PLAYING', 'TRANSITIONING'): return STATE_PLAYING - if self._status == 'OFF': - return STATE_OFF return STATE_IDLE @property @@ -330,15 +331,36 @@ class SonosEntity(MediaPlayerDevice): """Record that this player was seen right now.""" self._seen = time.monotonic() + if self._available: + return + + self._available = True + self._set_basic_information() + self._subscribe_to_player_events() + self.schedule_update_ha_state() + + def check_unseen(self): + """Make this player unavailable if it was not seen recently.""" + if not self._available: + return + + if self._seen < time.monotonic() - 2*DISCOVERY_INTERVAL: + self._available = False + + def _unsub(subscriptions): + for subscription in subscriptions: + subscription.unsubscribe() + self.hass.add_job(_unsub, self._subscriptions) + + self._subscriptions = [] + + self.schedule_update_ha_state() + @property def available(self) -> bool: """Return True if entity is available.""" return self._available - def _check_available(self): - """Check that we saw the player recently.""" - return self._seen > time.monotonic() - 2*DISCOVERY_INTERVAL - def _set_basic_information(self): """Set initial entity information.""" speaker_info = self.soco.get_speaker_info(True) @@ -390,30 +412,7 @@ class SonosEntity(MediaPlayerDevice): def update(self): """Retrieve latest state.""" - available = self._check_available() - if self._available != available: - self._available = available - if available: - self._set_basic_information() - self._subscribe_to_player_events() - else: - for subscription in self._subscriptions: - subscription.unsubscribe() - self._subscriptions = [] - - self._player_volume = None - self._player_muted = None - self._status = 'OFF' - self._coordinator = None - self._media_duration = None - self._media_position = None - self._media_position_updated_at = None - self._media_image_url = None - self._media_artist = None - self._media_album_name = None - self._media_title = None - self._source_name = None - elif available and not self._receives_events: + if self._available and not self._receives_events: try: self.update_groups() self.update_volume() @@ -433,6 +432,9 @@ class SonosEntity(MediaPlayerDevice): self._shuffle = self.soco.shuffle + update_position = (new_status != self._status) + self._status = new_status + if self.soco.is_playing_tv: self.update_media_linein(SOURCE_TV) elif self.soco.is_playing_line_in: @@ -444,11 +446,8 @@ class SonosEntity(MediaPlayerDevice): variables = event and event.variables self.update_media_radio(variables, track_info) else: - update_position = (new_status != self._status) self.update_media_music(update_position, track_info) - self._status = new_status - self.schedule_update_ha_state() # Also update slaves @@ -550,7 +549,9 @@ class SonosEntity(MediaPlayerDevice): self._media_position is None # position jumped? - if rel_time is not None and self._media_position is not None: + if (self.state == STATE_PLAYING + and rel_time is not None + and self._media_position is not None): time_diff = utcnow() - self._media_position_updated_at time_diff = time_diff.total_seconds() @@ -800,16 +801,6 @@ class SonosEntity(MediaPlayerDevice): return sources - @soco_error() - def turn_on(self): - """Turn the media player on.""" - self.media_play() - - @soco_error() - def turn_off(self): - """Turn off media player.""" - self.media_stop() - @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_play(self): diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py index 5431cd6260c..975706dd06a 100644 --- a/homeassistant/components/spaceapi/__init__.py +++ b/homeassistant/components/spaceapi/__init__.py @@ -28,6 +28,7 @@ ATTR_SPACE = 'space' ATTR_UNIT = 'unit' ATTR_URL = 'url' ATTR_VALUE = 'value' +ATTR_SENSOR_LOCATION = 'location' CONF_CONTACT = 'contact' CONF_HUMIDITY = 'humidity' @@ -72,9 +73,10 @@ STATE_SCHEMA = vol.Schema({ vol.Inclusive(CONF_ICON_OPEN, CONF_ICONS): cv.url, }, required=False) -SENSOR_SCHEMA = vol.Schema( - {vol.In(SENSOR_TYPES): [cv.entity_id]} -) +SENSOR_SCHEMA = vol.Schema({ + vol.In(SENSOR_TYPES): [cv.entity_id], + cv.string: [cv.entity_id] +}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -105,6 +107,27 @@ class APISpaceApiView(HomeAssistantView): url = URL_API_SPACEAPI name = 'api:spaceapi' + @staticmethod + def get_sensor_data(hass, spaceapi, sensor): + """Get data from a sensor.""" + sensor_state = hass.states.get(sensor) + if not sensor_state: + return None + sensor_data = { + ATTR_NAME: sensor_state.name, + ATTR_VALUE: sensor_state.state + } + if ATTR_SENSOR_LOCATION in sensor_state.attributes: + sensor_data[ATTR_LOCATION] = \ + sensor_state.attributes[ATTR_SENSOR_LOCATION] + else: + sensor_data[ATTR_LOCATION] = spaceapi[CONF_SPACE] + # Some sensors don't have a unit of measurement + if ATTR_UNIT_OF_MEASUREMENT in sensor_state.attributes: + sensor_data[ATTR_UNIT] = \ + sensor_state.attributes[ATTR_UNIT_OF_MEASUREMENT] + return sensor_data + @ha.callback def get(self, request): """Get SpaceAPI data.""" @@ -154,15 +177,7 @@ class APISpaceApiView(HomeAssistantView): for sensor_type in is_sensors: sensors[sensor_type] = [] for sensor in spaceapi['sensors'][sensor_type]: - sensor_state = hass.states.get(sensor) - unit = sensor_state.attributes[ATTR_UNIT_OF_MEASUREMENT] - value = sensor_state.state - sensor_data = { - ATTR_LOCATION: spaceapi[CONF_SPACE], - ATTR_NAME: sensor_state.name, - ATTR_UNIT: unit, - ATTR_VALUE: value, - } + sensor_data = self.get_sensor_data(hass, spaceapi, sensor) sensors[sensor_type].append(sensor_data) data[ATTR_SENSORS] = sensors diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index d25d2f03fce..c6b995963d9 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -128,7 +128,7 @@ async def async_setup_platform(hass, config, async_add_entities, update_tasks.append(player.async_update_ha_state(True)) if update_tasks: - await asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks) for service in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[service]['schema'] @@ -179,7 +179,7 @@ class LogitechMediaServer: try: websession = async_get_clientsession(self.hass) - with async_timeout.timeout(TIMEOUT, loop=self.hass.loop): + with async_timeout.timeout(TIMEOUT): response = await websession.post( url, data=data, diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py new file mode 100644 index 00000000000..79c9cd94871 --- /dev/null +++ b/homeassistant/components/ssdp/__init__.py @@ -0,0 +1,175 @@ +"""The SSDP integration.""" +import asyncio +from datetime import timedelta +import logging +from urllib.parse import urlparse +from xml.etree import ElementTree + +import aiohttp +from netdisco import ssdp, util + +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.generated.ssdp import SSDP + +DOMAIN = 'ssdp' +SCAN_INTERVAL = timedelta(seconds=60) + +ATTR_HOST = 'host' +ATTR_PORT = 'port' +ATTR_SSDP_DESCRIPTION = 'ssdp_description' +ATTR_ST = 'ssdp_st' +ATTR_NAME = 'name' +ATTR_MODEL_NAME = 'model_name' +ATTR_MODEL_NUMBER = 'model_number' +ATTR_SERIAL = 'serial_number' +ATTR_MANUFACTURER = 'manufacturer' +ATTR_MANUFACTURERURL = 'manufacturerURL' +ATTR_UDN = 'udn' +ATTR_UPNP_DEVICE_TYPE = 'upnp_device_type' + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Set up the SSDP integration.""" + async def initialize(): + scanner = Scanner(hass) + await scanner.async_scan(None) + async_track_time_interval(hass, scanner.async_scan, SCAN_INTERVAL) + + hass.loop.create_task(initialize()) + + return True + + +class Scanner: + """Class to manage SSDP scanning.""" + + def __init__(self, hass): + """Initialize class.""" + self.hass = hass + self.seen = set() + self._description_cache = {} + + async def async_scan(self, _): + """Scan for new entries.""" + _LOGGER.debug("Scanning") + # Run 3 times as packets can get lost + for _ in range(3): + entries = await self.hass.async_add_executor_job(ssdp.scan) + await self._process_entries(entries) + + # We clear the cache after each run. We track discovered entries + # so will never need a description twice. + self._description_cache.clear() + + async def _process_entries(self, entries): + """Process SSDP entries.""" + tasks = [] + + for entry in entries: + key = (entry.st, entry.location) + + if key in self.seen: + continue + + self.seen.add(key) + + tasks.append(self._process_entry(entry)) + + if not tasks: + return + + to_load = [result for result in await asyncio.gather(*tasks) + if result is not None] + + if not to_load: + return + + tasks = [] + + for entry, info, domains in to_load: + for domain in domains: + _LOGGER.debug("Discovered %s at %s", domain, entry.location) + tasks.append(self.hass.config_entries.flow.async_init( + domain, context={'source': DOMAIN}, data=info + )) + + await asyncio.wait(tasks) + + async def _process_entry(self, entry): + """Process a single entry.""" + domains = set(SSDP["st"].get(entry.st, [])) + + xml_location = entry.location + + if not xml_location: + if domains: + return (entry, info_from_entry(entry, None), domains) + return None + + # Multiple entries usally share same location. Make sure + # we fetch it only once. + info_req = self._description_cache.get(xml_location) + + if info_req is None: + info_req = self._description_cache[xml_location] = \ + self.hass.async_create_task( + self._fetch_description(xml_location)) + + info = await info_req + + domains.update(SSDP["manufacturer"].get(info.get('manufacturer'), [])) + domains.update(SSDP["device_type"].get(info.get('deviceType'), [])) + + if domains: + return (entry, info_from_entry(entry, info), domains) + + return None + + async def _fetch_description(self, xml_location): + """Fetch an XML description.""" + session = self.hass.helpers.aiohttp_client.async_get_clientsession() + try: + resp = await session.get(xml_location, timeout=5) + xml = await resp.text() + + # Samsung Smart TV sometimes returns an empty document the + # first time. Retry once. + if not xml: + resp = await session.get(xml_location, timeout=5) + xml = await resp.text() + except (aiohttp.ClientError, asyncio.TimeoutError) as err: + _LOGGER.debug("Error fetching %s: %s", xml_location, err) + return {} + + try: + tree = ElementTree.fromstring(xml) + except ElementTree.ParseError as err: + _LOGGER.debug("Error parsing %s: %s", xml_location, err) + return {} + + return util.etree_to_dict(tree).get('root', {}).get('device', {}) + + +def info_from_entry(entry, device_info): + """Get most important info from an entry.""" + url = urlparse(entry.location) + info = { + ATTR_HOST: url.hostname, + ATTR_PORT: url.port, + ATTR_SSDP_DESCRIPTION: entry.location, + ATTR_ST: entry.st, + } + + if device_info: + info[ATTR_NAME] = device_info.get('friendlyName') + info[ATTR_MODEL_NAME] = device_info.get('modelName') + info[ATTR_MODEL_NUMBER] = device_info.get('modelNumber') + info[ATTR_SERIAL] = device_info.get('serialNumber') + info[ATTR_MANUFACTURER] = device_info.get('manufacturer') + info[ATTR_MANUFACTURERURL] = device_info.get('manufacturerURL') + info[ATTR_UDN] = device_info.get('UDN') + info[ATTR_UPNP_DEVICE_TYPE] = device_info.get('deviceType') + + return info diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json new file mode 100644 index 00000000000..ce00bcbc888 --- /dev/null +++ b/homeassistant/components/ssdp/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "ssdp", + "name": "SSDP", + "documentation": "https://www.home-assistant.io/components/ssdp", + "requirements": [ + "netdisco==2.6.0" + ], + "dependencies": [ + ], + "codeowners": [ + ] +} diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index fe2c35c39b7..f384bae005b 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -138,7 +138,7 @@ class StartcaData: _LOGGER.debug("Updating Start.ca usage data") url = 'https://www.start.ca/support/usage/api?key=' + \ self.api_key - with async_timeout.timeout(REQUEST_TIMEOUT, loop=self.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): req = await self.websession.get(url) if req.status != 200: _LOGGER.error("Request failed with status: %u", req.status) diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index 90718fd540e..dda692a8d80 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -6,10 +6,9 @@ from homeassistant.const import ( CONF_ELEVATION, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET) from homeassistant.core import callback from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import ( - async_track_point_in_utc_time, async_track_utc_time_change) +from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.sun import ( - get_astral_location, get_astral_event_next) + get_astral_location, get_location_astral_event_next) from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -23,6 +22,7 @@ STATE_BELOW_HORIZON = 'below_horizon' STATE_ATTR_AZIMUTH = 'azimuth' STATE_ATTR_ELEVATION = 'elevation' +STATE_ATTR_RISING = 'rising' STATE_ATTR_NEXT_DAWN = 'next_dawn' STATE_ATTR_NEXT_DUSK = 'next_dusk' STATE_ATTR_NEXT_MIDNIGHT = 'next_midnight' @@ -30,6 +30,39 @@ STATE_ATTR_NEXT_NOON = 'next_noon' STATE_ATTR_NEXT_RISING = 'next_rising' STATE_ATTR_NEXT_SETTING = 'next_setting' +# The algorithm used here is somewhat complicated. It aims to cut down +# the number of sensor updates over the day. It's documented best in +# the PR for the change, see the Discussion section of: +# https://github.com/home-assistant/home-assistant/pull/23832 + + +# As documented in wikipedia: https://en.wikipedia.org/wiki/Twilight +# sun is: +# < -18° of horizon - all stars visible +PHASE_NIGHT = 'night' +# 18°-12° - some stars not visible +PHASE_ASTRONOMICAL_TWILIGHT = 'astronomical_twilight' +# 12°-6° - horizon visible +PHASE_NAUTICAL_TWILIGHT = 'nautical_twilight' +# 6°-0° - objects visible +PHASE_TWILIGHT = 'twilight' +# 0°-10° above horizon, sun low on horizon +PHASE_SMALL_DAY = 'small_day' +# > 10° above horizon +PHASE_DAY = 'day' + +# 4 mins is one degree of arc change of the sun on its circle. +# During the night and the middle of the day we don't update +# that much since it's not important. +_PHASE_UPDATES = { + PHASE_NIGHT: timedelta(minutes=4*5), + PHASE_ASTRONOMICAL_TWILIGHT: timedelta(minutes=4*2), + PHASE_NAUTICAL_TWILIGHT: timedelta(minutes=4*2), + PHASE_TWILIGHT: timedelta(minutes=4), + PHASE_SMALL_DAY: timedelta(minutes=2), + PHASE_DAY: timedelta(minutes=4), +} + async def async_setup(hass, config): """Track the state of the sun.""" @@ -37,10 +70,7 @@ async def async_setup(hass, config): _LOGGER.warning( "Elevation is now configured in home assistant core. " "See https://home-assistant.io/docs/configuration/basic/") - - sun = Sun(hass, get_astral_location(hass)) - sun.point_in_time_listener(dt_util.utcnow()) - + Sun(hass, get_astral_location(hass)) return True @@ -57,8 +87,10 @@ class Sun(Entity): self.next_dawn = self.next_dusk = None self.next_midnight = self.next_noon = None self.solar_elevation = self.solar_azimuth = None + self.rising = self.phase = None - async_track_utc_time_change(hass, self.timer_update, second=30) + self._next_change = None + self.update_events(dt_util.utcnow()) @property def name(self): @@ -83,57 +115,110 @@ class Sun(Entity): STATE_ATTR_NEXT_NOON: self.next_noon.isoformat(), STATE_ATTR_NEXT_RISING: self.next_rising.isoformat(), STATE_ATTR_NEXT_SETTING: self.next_setting.isoformat(), - STATE_ATTR_ELEVATION: round(self.solar_elevation, 2), - STATE_ATTR_AZIMUTH: round(self.solar_azimuth, 2) + STATE_ATTR_ELEVATION: self.solar_elevation, + STATE_ATTR_AZIMUTH: self.solar_azimuth, + STATE_ATTR_RISING: self.rising, } - @property - def next_change(self): - """Datetime when the next change to the state is.""" - return min(self.next_dawn, self.next_dusk, self.next_midnight, - self.next_noon, self.next_rising, self.next_setting) + def _check_event(self, utc_point_in_time, event, before): + next_utc = get_location_astral_event_next( + self.location, event, utc_point_in_time) + if next_utc < self._next_change: + self._next_change = next_utc + self.phase = before + return next_utc @callback - def update_as_of(self, utc_point_in_time): + def update_events(self, utc_point_in_time): """Update the attributes containing solar events.""" - self.next_dawn = get_astral_event_next( - self.hass, 'dawn', utc_point_in_time) - self.next_dusk = get_astral_event_next( - self.hass, 'dusk', utc_point_in_time) - self.next_midnight = get_astral_event_next( - self.hass, 'solar_midnight', utc_point_in_time) - self.next_noon = get_astral_event_next( - self.hass, 'solar_noon', utc_point_in_time) - self.next_rising = get_astral_event_next( - self.hass, SUN_EVENT_SUNRISE, utc_point_in_time) - self.next_setting = get_astral_event_next( - self.hass, SUN_EVENT_SUNSET, utc_point_in_time) + self._next_change = utc_point_in_time + timedelta(days=400) + + # Work our way around the solar cycle, figure out the next + # phase. Some of these are stored. + self.location.solar_depression = 'astronomical' + self._check_event(utc_point_in_time, 'dawn', PHASE_NIGHT) + self.location.solar_depression = 'nautical' + self._check_event( + utc_point_in_time, 'dawn', PHASE_ASTRONOMICAL_TWILIGHT) + self.location.solar_depression = 'civil' + self.next_dawn = self._check_event( + utc_point_in_time, 'dawn', PHASE_NAUTICAL_TWILIGHT) + self.next_rising = self._check_event( + utc_point_in_time, SUN_EVENT_SUNRISE, PHASE_TWILIGHT) + self.location.solar_depression = -10 + self._check_event(utc_point_in_time, 'dawn', PHASE_SMALL_DAY) + self.next_noon = self._check_event( + utc_point_in_time, 'solar_noon', None) + self._check_event(utc_point_in_time, 'dusk', PHASE_DAY) + self.next_setting = self._check_event( + utc_point_in_time, SUN_EVENT_SUNSET, PHASE_SMALL_DAY) + self.location.solar_depression = 'civil' + self.next_dusk = self._check_event( + utc_point_in_time, 'dusk', PHASE_TWILIGHT) + self.location.solar_depression = 'nautical' + self._check_event( + utc_point_in_time, 'dusk', PHASE_NAUTICAL_TWILIGHT) + self.location.solar_depression = 'astronomical' + self._check_event( + utc_point_in_time, 'dusk', PHASE_ASTRONOMICAL_TWILIGHT) + self.next_midnight = self._check_event( + utc_point_in_time, 'solar_midnight', None) + + # if the event was solar midday or midnight, phase will now + # be None. Solar noon doesn't always happen when the sun is + # even in the day at the poles, so we can't rely on it. + # Need to calculate phase if next is noon or midnight + if self.phase is None: + elevation = self.location.solar_elevation(self._next_change) + if elevation >= 10: + self.phase = PHASE_DAY + elif elevation >= 0: + self.phase = PHASE_SMALL_DAY + elif elevation >= -6: + self.phase = PHASE_TWILIGHT + elif elevation >= -12: + self.phase = PHASE_NAUTICAL_TWILIGHT + elif elevation >= -18: + self.phase = PHASE_ASTRONOMICAL_TWILIGHT + else: + self.phase = PHASE_NIGHT + + self.rising = self.next_noon < self.next_midnight + + _LOGGER.debug( + "sun phase_update@%s: phase=%s", + utc_point_in_time.isoformat(), + self.phase, + ) + self.update_sun_position(utc_point_in_time) + + # Set timer for the next solar event + async_track_point_in_utc_time( + self.hass, self.update_events, + self._next_change) + _LOGGER.debug("next time: %s", self._next_change.isoformat()) @callback def update_sun_position(self, utc_point_in_time): """Calculate the position of the sun.""" - self.solar_azimuth = self.location.solar_azimuth(utc_point_in_time) - self.solar_elevation = self.location.solar_elevation(utc_point_in_time) + self.solar_azimuth = round( + self.location.solar_azimuth(utc_point_in_time), 2) + self.solar_elevation = round( + self.location.solar_elevation(utc_point_in_time), 2) - @callback - def point_in_time_listener(self, now): - """Run when the state of the sun has changed.""" - self.update_sun_position(now) - self.update_as_of(now) + _LOGGER.debug( + "sun position_update@%s: elevation=%s azimuth=%s", + utc_point_in_time.isoformat(), + self.solar_elevation, self.solar_azimuth + ) self.async_write_ha_state() - _LOGGER.debug("sun point_in_time_listener@%s: %s, %s", - now, self.state, self.state_attributes) - # Schedule next update at next_change+1 second so sun state has changed + # Next update as per the current phase + delta = _PHASE_UPDATES[self.phase] + # if the next update is within 1.25 of the next + # position update just drop it + if utc_point_in_time + delta*1.25 > self._next_change: + return async_track_point_in_utc_time( - self.hass, self.point_in_time_listener, - self.next_change + timedelta(seconds=1)) - _LOGGER.debug("next time: %s", self.next_change + timedelta(seconds=1)) - - @callback - def timer_update(self, time): - """Needed to update solar elevation and azimuth.""" - self.update_sun_position(time) - self.async_write_ha_state() - _LOGGER.debug("sun timer_update@%s: %s, %s", - time, self.state, self.state_attributes) + self.hass, self.update_sun_position, + utc_point_in_time + delta) diff --git a/homeassistant/components/sun/manifest.json b/homeassistant/components/sun/manifest.json index 2ef89da8f69..e55131306dc 100644 --- a/homeassistant/components/sun/manifest.json +++ b/homeassistant/components/sun/manifest.json @@ -5,6 +5,6 @@ "requirements": [], "dependencies": [], "codeowners": [ - "@home-assistant/core" + "@Swamp-Ig" ] } diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 43ca0abc2a0..8f959369b7b 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -62,8 +62,7 @@ async def async_setup(hass: HomeAssistantType, config: Dict) -> bool: EVENT_HOMEASSISTANT_STOP, async_stop_bridge)) try: - device_data = await wait_for( - v2bridge.queue.get(), timeout=5.0, loop=hass.loop) + device_data = await wait_for(v2bridge.queue.get(), timeout=10.0) except (Asyncio_TimeoutError, RuntimeError): _LOGGER.exception("failed to get response from device") await v2bridge.stop() diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py index 57dbb7134e2..6330b14f7c4 100644 --- a/homeassistant/components/synology_srm/device_tracker.py +++ b/homeassistant/components/synology_srm/device_tracker.py @@ -80,7 +80,7 @@ class SynologySrmDeviceScanner(DeviceScanner): """Check the router for connected devices.""" _LOGGER.debug("Scanning for connected devices") - devices = self.client.mesh.network_wifidevice() + devices = self.client.core.network_nsm_device({'is_online': True}) last_results = [] for device in devices: diff --git a/homeassistant/components/synology_srm/manifest.json b/homeassistant/components/synology_srm/manifest.json index fa89577f26e..a790a6c453c 100644 --- a/homeassistant/components/synology_srm/manifest.json +++ b/homeassistant/components/synology_srm/manifest.json @@ -1,9 +1,9 @@ { "domain": "synology_srm", - "name": "Synology srm", + "name": "Synology SRM", "documentation": "https://www.home-assistant.io/components/synology_srm", "requirements": [ - "synology-srm==0.0.6" + "synology-srm==0.0.7" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 7812bbd812b..3bb62f328b9 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -61,7 +61,7 @@ class TadoDeviceScanner(DeviceScanner): self.tadoapiurl += '?username={username}&password={password}' self.websession = async_create_clientsession( - hass, cookie_jar=aiohttp.CookieJar(unsafe=True, loop=hass.loop)) + hass, cookie_jar=aiohttp.CookieJar(unsafe=True)) self.success_init = asyncio.run_coroutine_threadsafe( self._async_update_info(), hass.loop diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index ca1651eca68..d30dafd8da4 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -119,6 +119,11 @@ class TautulliSensor(Entity): """Return the icon of the sensor.""" return 'mdi:plex' + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return "Watching" + @property def device_state_attributes(self): """Return attributes for the sensor.""" diff --git a/homeassistant/components/teksavvy/sensor.py b/homeassistant/components/teksavvy/sensor.py index de74ceda9f5..78b6e26b1ef 100644 --- a/homeassistant/components/teksavvy/sensor.py +++ b/homeassistant/components/teksavvy/sensor.py @@ -132,7 +132,7 @@ class TekSavvyData: _LOGGER.debug("Updating TekSavvy data") url = "https://api.teksavvy.com/"\ "web/Usage/UsageSummaryRecords?$filter=IsCurrent%20eq%20true" - with async_timeout.timeout(REQUEST_TIMEOUT, loop=self.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): req = await self.websession.get(url, headers=headers) if req.status != 200: _LOGGER.error("Request failed with status: %u", req.status) diff --git a/homeassistant/components/tellduslive/manifest.json b/homeassistant/components/tellduslive/manifest.json index 2e6233f426c..7f431ba92b1 100644 --- a/homeassistant/components/tellduslive/manifest.json +++ b/homeassistant/components/tellduslive/manifest.json @@ -1,6 +1,7 @@ { "domain": "tellduslive", "name": "Tellduslive", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/tellduslive", "requirements": [ "tellduslive==0.10.10" diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index e9073c4f98c..abfe07747d5 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -124,7 +124,7 @@ class TtnDataStorage: """Get the current state from The Things Network Data Storage.""" try: session = async_get_clientsession(self._hass) - with async_timeout.timeout(DEFAULT_TIMEOUT, loop=self._hass.loop): + with async_timeout.timeout(DEFAULT_TIMEOUT): response = await session.get(self._url, headers=self._headers) except (asyncio.TimeoutError, aiohttp.ClientError): diff --git a/homeassistant/components/toon/manifest.json b/homeassistant/components/toon/manifest.json index 7dbf6768db6..eccaf7df9bc 100644 --- a/homeassistant/components/toon/manifest.json +++ b/homeassistant/components/toon/manifest.json @@ -1,6 +1,7 @@ { "domain": "toon", "name": "Toon", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/toon", "requirements": [ "toonapilib==3.2.2" diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 2ebf342c38d..4173c1aaa60 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -5,14 +5,12 @@ import voluptuous as vol from homeassistant.const import CONF_HOST from homeassistant import config_entries -from homeassistant.helpers import config_entry_flow import homeassistant.helpers.config_validation as cv - +from .config_flow import async_get_devices +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -DOMAIN = 'tplink' - TPLINK_HOST_SCHEMA = vol.Schema({ vol.Required(CONF_HOST): cv.string }) @@ -34,16 +32,6 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -async def _async_has_devices(hass): - """Return if there are devices that can be discovered.""" - from pyHS100 import Discover - - def discover(): - devs = Discover.discover() - return devs - return await hass.async_add_executor_job(discover) - - async def async_setup(hass, config): """Set up the TP-Link component.""" conf = config.get(DOMAIN) @@ -74,7 +62,7 @@ async def async_setup_entry(hass, config_entry): # If initialized from configure integrations, there's no config # so we default here to True if config_data is None or config_data[CONF_DISCOVERY]: - devs = await _async_has_devices(hass) + devs = await async_get_devices(hass) _LOGGER.info("Discovered %s TP-Link smart home device(s)", len(devs)) devices.update(devs) @@ -149,9 +137,3 @@ async def async_unload_entry(hass, entry): # We were not able to unload the platforms, either because there # were none or one of the forward_unloads failed. return False - - -config_entry_flow.register_discovery_flow(DOMAIN, - 'TP-Link Smart Home', - _async_has_devices, - config_entries.CONN_CLASS_LOCAL_POLL) diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py new file mode 100644 index 00000000000..86b1acf4ff1 --- /dev/null +++ b/homeassistant/components/tplink/config_flow.py @@ -0,0 +1,20 @@ +"""Config flow for TP-Link.""" +from homeassistant.helpers import config_entry_flow +from homeassistant import config_entries +from .const import DOMAIN + + +async def async_get_devices(hass): + """Return if there are devices that can be discovered.""" + from pyHS100 import Discover + + def discover(): + devs = Discover.discover() + return devs + return await hass.async_add_executor_job(discover) + + +config_entry_flow.register_discovery_flow(DOMAIN, + 'TP-Link Smart Home', + async_get_devices, + config_entries.CONN_CLASS_LOCAL_POLL) diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py new file mode 100644 index 00000000000..583c25e285c --- /dev/null +++ b/homeassistant/components/tplink/const.py @@ -0,0 +1,3 @@ +"""Const for TP-Link.""" + +DOMAIN = "tplink" diff --git a/homeassistant/components/tplink/device_tracker.py b/homeassistant/components/tplink/device_tracker.py index 7b665006a44..b139aed4eea 100644 --- a/homeassistant/components/tplink/device_tracker.py +++ b/homeassistant/components/tplink/device_tracker.py @@ -41,6 +41,12 @@ def get_scanner(hass, config): should be gradually migrated in the pypi package """ + _LOGGER.warning("TP-Link device tracker is unmaintained and will be " + "removed in the future releases if no maintainer is " + "found. If you have interest in this integration, " + "feel free to create a pull request to move this code " + "to a new 'tplink_router' integration and refactoring " + "the device-specific parts to the tplink library") for cls in [ TplinkDeviceScanner, Tplink5DeviceScanner, Tplink4DeviceScanner, Tplink3DeviceScanner, Tplink2DeviceScanner, Tplink1DeviceScanner diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index d164a526fc0..e0f85757afd 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -1,6 +1,7 @@ { "domain": "tplink", "name": "Tplink", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/tplink", "requirements": [ "pyHS100==0.3.5", diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 0ad269b8780..76f6a8f5764 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -76,8 +76,8 @@ class FlowHandler(config_entries.ConfigFlow): errors=errors, ) - async def async_step_discovery(self, user_input): - """Handle discovery.""" + async def async_step_zeroconf(self, user_input): + """Handle zeroconf discovery.""" for entry in self._async_current_entries(): if entry.data[CONF_HOST] == user_input['host']: return self.async_abort( @@ -143,7 +143,7 @@ async def authenticate(hass, host, security_code): identity = uuid4().hex - api_factory = APIFactory(host, psk_id=identity, loop=hass.loop) + api_factory = APIFactory(host, psk_id=identity) try: with async_timeout.timeout(5): diff --git a/homeassistant/components/tradfri/manifest.json b/homeassistant/components/tradfri/manifest.json index 19e8348e987..aba3805a4aa 100644 --- a/homeassistant/components/tradfri/manifest.json +++ b/homeassistant/components/tradfri/manifest.json @@ -1,11 +1,13 @@ { "domain": "tradfri", "name": "Tradfri", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/tradfri", "requirements": [ "pytradfri[async]==6.0.1" ], "dependencies": [], + "zeroconf": ["_coap._udp.local."], "codeowners": [ "@ggravlingen" ] diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 8af22fbb460..559cc4a16e6 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -165,7 +165,7 @@ async def async_setup(hass, config): in config_per_platform(config, DOMAIN)] if setup_tasks: - await asyncio.wait(setup_tasks, loop=hass.loop) + await asyncio.wait(setup_tasks) async def async_clear_cache_handle(service): """Handle clear cache service call.""" diff --git a/homeassistant/components/twilio/__init__.py b/homeassistant/components/twilio/__init__.py index 82011f499ba..8a1babaf1eb 100644 --- a/homeassistant/components/twilio/__init__.py +++ b/homeassistant/components/twilio/__init__.py @@ -4,8 +4,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.helpers import config_entry_flow - -DOMAIN = 'twilio' +from .const import DOMAIN CONF_ACCOUNT_SID = 'account_sid' CONF_AUTH_TOKEN = 'auth_token' @@ -60,14 +59,3 @@ async def async_unload_entry(hass, entry): # pylint: disable=invalid-name async_remove_entry = config_entry_flow.webhook_async_remove_entry - - -config_entry_flow.register_webhook_flow( - DOMAIN, - 'Twilio Webhook', - { - 'twilio_url': - 'https://www.twilio.com/docs/glossary/what-is-a-webhook', - 'docs_url': 'https://www.home-assistant.io/components/twilio/' - } -) diff --git a/homeassistant/components/twilio/config_flow.py b/homeassistant/components/twilio/config_flow.py new file mode 100644 index 00000000000..686b6391b05 --- /dev/null +++ b/homeassistant/components/twilio/config_flow.py @@ -0,0 +1,15 @@ +"""Config flow for Twilio.""" +from homeassistant.helpers import config_entry_flow + +from .const import DOMAIN + + +config_entry_flow.register_webhook_flow( + DOMAIN, + 'Twilio Webhook', + { + 'twilio_url': + 'https://www.twilio.com/docs/glossary/what-is-a-webhook', + 'docs_url': 'https://www.home-assistant.io/components/twilio/' + } +) diff --git a/homeassistant/components/twilio/const.py b/homeassistant/components/twilio/const.py new file mode 100644 index 00000000000..7ca44590d6a --- /dev/null +++ b/homeassistant/components/twilio/const.py @@ -0,0 +1,3 @@ +"""Const for Twilio.""" + +DOMAIN = "twilio" diff --git a/homeassistant/components/twilio/manifest.json b/homeassistant/components/twilio/manifest.json index dfb7dd4b14d..f96afa18115 100644 --- a/homeassistant/components/twilio/manifest.json +++ b/homeassistant/components/twilio/manifest.json @@ -1,6 +1,7 @@ { "domain": "twilio", "name": "Twilio", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/twilio", "requirements": [ "twilio==6.19.1" diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 3af450acdbf..33b687bd178 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -1,20 +1,9 @@ """Support for devices connected to UniFi POE.""" -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL) +from homeassistant.const import CONF_HOST from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from .const import (CONF_CONTROLLER, CONF_POE_CONTROL, CONF_SITE_ID, - CONTROLLER_ID, DOMAIN, LOGGER) -from .controller import UniFiController, get_controller -from .errors import ( - AlreadyConfigured, AuthenticationRequired, CannotConnect, UserLevel) - -DEFAULT_PORT = 8443 -DEFAULT_SITE_ID = 'default' -DEFAULT_VERIFY_SSL = False +from .const import CONF_CONTROLLER, CONF_SITE_ID, CONTROLLER_ID, DOMAIN +from .controller import UniFiController async def async_setup(hass, config): @@ -64,116 +53,3 @@ async def async_unload_entry(hass, config_entry): ) controller = hass.data[DOMAIN].pop(controller_id) return await controller.async_reset() - - -@config_entries.HANDLERS.register(DOMAIN) -class UnifiFlowHandler(config_entries.ConfigFlow): - """Handle a UniFi config flow.""" - - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - - def __init__(self): - """Initialize the UniFi flow.""" - self.config = None - self.desc = None - self.sites = None - - async def async_step_user(self, user_input=None): - """Handle a flow initialized by the user.""" - errors = {} - - if user_input is not None: - - try: - self.config = { - CONF_HOST: user_input[CONF_HOST], - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_PORT: user_input.get(CONF_PORT), - CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL), - CONF_SITE_ID: DEFAULT_SITE_ID, - } - controller = await get_controller(self.hass, **self.config) - - self.sites = await controller.sites() - - return await self.async_step_site() - - except AuthenticationRequired: - errors['base'] = 'faulty_credentials' - - except CannotConnect: - errors['base'] = 'service_unavailable' - - except Exception: # pylint: disable=broad-except - LOGGER.error( - 'Unknown error connecting with UniFi Controller at %s', - user_input[CONF_HOST]) - return self.async_abort(reason='unknown') - - return self.async_show_form( - step_id='user', - data_schema=vol.Schema({ - vol.Required(CONF_HOST): str, - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional( - CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, - }), - errors=errors, - ) - - async def async_step_site(self, user_input=None): - """Select site to control.""" - errors = {} - - if user_input is not None: - - try: - desc = user_input.get(CONF_SITE_ID, self.desc) - for site in self.sites.values(): - if desc == site['desc']: - if site['role'] != 'admin': - raise UserLevel - self.config[CONF_SITE_ID] = site['name'] - break - - for entry in self._async_current_entries(): - controller = entry.data[CONF_CONTROLLER] - if controller[CONF_HOST] == self.config[CONF_HOST] and \ - controller[CONF_SITE_ID] == self.config[CONF_SITE_ID]: - raise AlreadyConfigured - - data = { - CONF_CONTROLLER: self.config, - CONF_POE_CONTROL: True - } - - return self.async_create_entry( - title=desc, - data=data - ) - - except AlreadyConfigured: - return self.async_abort(reason='already_configured') - - except UserLevel: - return self.async_abort(reason='user_privilege') - - if len(self.sites) == 1: - self.desc = next(iter(self.sites.values()))['desc'] - return await self.async_step_site(user_input={}) - - sites = [] - for site in self.sites.values(): - sites.append(site['desc']) - - return self.async_show_form( - step_id='site', - data_schema=vol.Schema({ - vol.Required(CONF_SITE_ID): vol.In(sites) - }), - errors=errors, - ) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py new file mode 100644 index 00000000000..b784aaa705a --- /dev/null +++ b/homeassistant/components/unifi/config_flow.py @@ -0,0 +1,130 @@ +"""Config flow for Unifi.""" +import voluptuous as vol + +from homeassistant import config_entries +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 .controller import get_controller +from .errors import ( + AlreadyConfigured, AuthenticationRequired, CannotConnect, UserLevel) + + +DEFAULT_PORT = 8443 +DEFAULT_SITE_ID = 'default' +DEFAULT_VERIFY_SSL = False + + +@config_entries.HANDLERS.register(DOMAIN) +class UnifiFlowHandler(config_entries.ConfigFlow): + """Handle a UniFi config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize the UniFi flow.""" + self.config = None + self.desc = None + self.sites = None + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + + try: + self.config = { + CONF_HOST: user_input[CONF_HOST], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_PORT: user_input.get(CONF_PORT), + CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL), + CONF_SITE_ID: DEFAULT_SITE_ID, + } + controller = await get_controller(self.hass, **self.config) + + self.sites = await controller.sites() + + return await self.async_step_site() + + except AuthenticationRequired: + errors['base'] = 'faulty_credentials' + + except CannotConnect: + errors['base'] = 'service_unavailable' + + except Exception: # pylint: disable=broad-except + LOGGER.error( + 'Unknown error connecting with UniFi Controller at %s', + user_input[CONF_HOST]) + return self.async_abort(reason='unknown') + + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional( + CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + }), + errors=errors, + ) + + async def async_step_site(self, user_input=None): + """Select site to control.""" + errors = {} + + if user_input is not None: + + try: + desc = user_input.get(CONF_SITE_ID, self.desc) + for site in self.sites.values(): + if desc == site['desc']: + if site['role'] != 'admin': + raise UserLevel + self.config[CONF_SITE_ID] = site['name'] + break + + for entry in self._async_current_entries(): + controller = entry.data[CONF_CONTROLLER] + if controller[CONF_HOST] == self.config[CONF_HOST] and \ + controller[CONF_SITE_ID] == self.config[CONF_SITE_ID]: + raise AlreadyConfigured + + data = { + CONF_CONTROLLER: self.config, + CONF_POE_CONTROL: True + } + + return self.async_create_entry( + title=desc, + data=data + ) + + except AlreadyConfigured: + return self.async_abort(reason='already_configured') + + except UserLevel: + return self.async_abort(reason='user_privilege') + + if len(self.sites) == 1: + self.desc = next(iter(self.sites.values()))['desc'] + return await self.async_step_site(user_input={}) + + sites = [] + for site in self.sites.values(): + sites.append(site['desc']) + + return self.async_show_form( + step_id='site', + data_schema=vol.Schema({ + vol.Required(CONF_SITE_ID): vol.In(sites) + }), + errors=errors, + ) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 85a84539663..22ece5addaf 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -1,6 +1,7 @@ { "domain": "unifi", "name": "Unifi", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/unifi", "requirements": [ "aiounifi==4", diff --git a/homeassistant/components/upc_connect/device_tracker.py b/homeassistant/components/upc_connect/device_tracker.py index 4b4c32182bd..cf8e548ac61 100644 --- a/homeassistant/components/upc_connect/device_tracker.py +++ b/homeassistant/components/upc_connect/device_tracker.py @@ -81,7 +81,7 @@ class UPCDeviceScanner(DeviceScanner): """Get first token.""" try: # get first token - with async_timeout.timeout(10, loop=self.hass.loop): + with async_timeout.timeout(10): response = await self.websession.get( "http://{}/common_page/login.html".format(self.host), headers=self.headers) @@ -99,7 +99,7 @@ class UPCDeviceScanner(DeviceScanner): async def _async_ws_function(self, function): """Execute a command on UPC firmware webservice.""" try: - with async_timeout.timeout(10, loop=self.hass.loop): + with async_timeout.timeout(10): # The 'token' parameter has to be first, and 'fun' second # or the UPC firmware will return an error response = await self.websession.post( diff --git a/homeassistant/components/updater/__init__.py b/homeassistant/components/updater/__init__.py index 95b1372418c..b7e8e47e8c2 100644 --- a/homeassistant/components/updater/__init__.py +++ b/homeassistant/components/updater/__init__.py @@ -136,7 +136,7 @@ async def get_newest_version(hass, huuid, include_components): session = async_get_clientsession(hass) try: - with async_timeout.timeout(5, loop=hass.loop): + with async_timeout.timeout(5): req = await session.post(UPDATER_URL, json=info_object) _LOGGER.info(("Submitted analytics to Home Assistant servers. " "Information submitted includes %s"), info_object) diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index fd2aa994ca4..219167366a5 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -7,7 +7,6 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.helpers import config_entry_flow from homeassistant.helpers import config_validation as cv from homeassistant.helpers import device_registry as dr from homeassistant.helpers import dispatcher @@ -204,10 +203,3 @@ async def async_unload_entry(hass: HomeAssistantType, dispatcher.async_dispatcher_send(hass, SIGNAL_REMOVE_SENSOR, device) return True - - -config_entry_flow.register_discovery_flow( - DOMAIN, - 'UPnP/IGD', - Device.async_discover, - config_entries.CONN_CLASS_LOCAL_POLL) diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py new file mode 100644 index 00000000000..65a91858b57 --- /dev/null +++ b/homeassistant/components/upnp/config_flow.py @@ -0,0 +1,13 @@ +"""Config flow for UPNP.""" +from homeassistant.helpers import config_entry_flow +from homeassistant import config_entries + +from .const import DOMAIN +from .device import Device + + +config_entry_flow.register_discovery_flow( + DOMAIN, + 'UPnP/IGD', + Device.async_discover, + config_entries.CONN_CLASS_LOCAL_POLL) diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 75213ecc9b9..4a189dc6dd1 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -1,6 +1,7 @@ { "domain": "upnp", "name": "Upnp", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/upnp", "requirements": [ "async-upnp-client==0.14.7" diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 45279fa8933..174395f5f3f 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -1,5 +1,4 @@ """Support for Västtrafik public transport.""" -from datetime import datetime from datetime import timedelta import logging @@ -10,6 +9,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +from homeassistant.util.dt import now _LOGGER = logging.getLogger(__name__) @@ -107,7 +107,7 @@ class VasttrafikDepartureSensor(Entity): self._departureboard = self._planner.departureboard( self._departure['id'], direction=self._heading['id'] if self._heading else None, - date=datetime.now()+self._delay) + date=now()+self._delay) except self._vasttrafik.Error: _LOGGER.debug("Unable to read departure board, updating token") self._planner.update_token() diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index e8b9158f721..cfa4dd6832d 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -3,7 +3,7 @@ "name": "Venstar", "documentation": "https://www.home-assistant.io/components/venstar", "requirements": [ - "venstarcolortouch==0.6" + "venstarcolortouch==0.7" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index dc73be056db..53c79098782 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -6,7 +6,7 @@ import homeassistant.components.alarm_control_panel as alarm from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) -from . import CONF_ALARM, CONF_CODE_DIGITS, HUB as hub +from . import CONF_ALARM, CONF_CODE_DIGITS, CONF_GIID, HUB as hub _LOGGER = logging.getLogger(__name__) @@ -45,6 +45,14 @@ class VerisureAlarm(alarm.AlarmControlPanel): @property def name(self): """Return the name of the device.""" + giid = hub.config.get(CONF_GIID) + if giid is not None: + aliass = {i['giid']: i['alias'] for i in hub.session.installations} + if giid in aliass.keys(): + return '{} alarm'.format(aliass[giid]) + + _LOGGER.error('Verisure installation giid not found: %s', giid) + return '{} alarm'.format(hub.session.installations[0]['alias']) @property diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index ee939d4a594..704cb77f5c8 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -67,7 +67,7 @@ async def async_http_request(hass, uri): """Perform actual request.""" try: session = hass.helpers.aiohttp_client.async_get_clientsession(hass) - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): req = await session.get(uri) if req.status != 200: return {'error': req.status} diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py index d5340e45b5c..eaa605ee265 100644 --- a/homeassistant/components/voicerss/tts.py +++ b/homeassistant/components/voicerss/tts.py @@ -116,7 +116,7 @@ class VoiceRSSProvider(Provider): form_data['hl'] = language try: - with async_timeout.timeout(10, loop=self.hass.loop): + with async_timeout.timeout(10): request = await websession.post( VOICERSS_API_URL, data=form_data ) diff --git a/homeassistant/components/watson_tts/__init__.py b/homeassistant/components/watson_tts/__init__.py new file mode 100644 index 00000000000..abdc9308ca3 --- /dev/null +++ b/homeassistant/components/watson_tts/__init__.py @@ -0,0 +1 @@ +"""Support for IBM Watson TTS integration.""" diff --git a/homeassistant/components/watson_tts/manifest.json b/homeassistant/components/watson_tts/manifest.json new file mode 100644 index 00000000000..d40baaca132 --- /dev/null +++ b/homeassistant/components/watson_tts/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "watson_tts", + "name": "IBM Watson TTS", + "documentation": "https://www.home-assistant.io/components/watson_tts", + "requirements": [ + "ibm-watson==3.0.3" + ], + "dependencies": [], + "codeowners": [ + "@rutkai" + ] +} diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py new file mode 100644 index 00000000000..be60908d096 --- /dev/null +++ b/homeassistant/components/watson_tts/tts.py @@ -0,0 +1,137 @@ +"""Support for IBM Watson TTS integration.""" +import logging + +import voluptuous as vol + +from homeassistant.components.tts import PLATFORM_SCHEMA, Provider +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_URL = 'watson_url' +CONF_APIKEY = 'watson_apikey' +ATTR_CREDENTIALS = 'credentials' + +DEFAULT_URL = 'https://stream.watsonplatform.net/text-to-speech/api' + +CONF_VOICE = 'voice' +CONF_OUTPUT_FORMAT = 'output_format' +CONF_TEXT_TYPE = 'text' + +# List from https://tinyurl.com/watson-tts-docs +SUPPORTED_VOICES = [ + "de-DE_BirgitVoice", + "de-DE_BirgitV2Voice", + "de-DE_DieterVoice", + "de-DE_DieterV2Voice" + "en-GB_KateVoice", + "en-US_AllisonVoice", + "en-US_AllisonV2Voice", + "en-US_LisaVoice", + "en-US_LisaV2Voice", + "en-US_MichaelVoice", + "en-US_MichaelV2Voice", + "es-ES_EnriqueVoice", + "es-ES_LauraVoice", + "es-LA_SofiaVoice", + "es-US_SofiaVoice", + "fr-FR_ReneeVoice", + "it-IT_FrancescaVoice", + "it-IT_FrancescaV2Voice", + "ja-JP_EmiVoice", + "pt-BR_IsabelaVoice" +] + +SUPPORTED_OUTPUT_FORMATS = [ + 'audio/flac', + 'audio/mp3', + 'audio/mpeg', + 'audio/ogg', + 'audio/ogg;codecs=opus', + 'audio/ogg;codecs=vorbis', + 'audio/wav' +] + +CONTENT_TYPE_EXTENSIONS = { + 'audio/flac': 'flac', + 'audio/mp3': 'mp3', + 'audio/mpeg': 'mp3', + 'audio/ogg': 'ogg', + 'audio/ogg;codecs=opus': 'ogg', + 'audio/ogg;codecs=vorbis': 'ogg', + 'audio/wav': 'wav', +} + +DEFAULT_VOICE = 'en-US_AllisonVoice' +DEFAULT_OUTPUT_FORMAT = 'audio/mp3' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_URL, default=DEFAULT_URL): cv.string, + vol.Required(CONF_APIKEY): cv.string, + vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): vol.In(SUPPORTED_VOICES), + vol.Optional(CONF_OUTPUT_FORMAT, default=DEFAULT_OUTPUT_FORMAT): + vol.In(SUPPORTED_OUTPUT_FORMATS), +}) + + +def get_engine(hass, config): + """Set up IBM Watson TTS component.""" + from ibm_watson import TextToSpeechV1 + + service = TextToSpeechV1( + url=config[CONF_URL], + iam_apikey=config[CONF_APIKEY] + ) + + supported_languages = list({s[:5] for s in SUPPORTED_VOICES}) + default_voice = config[CONF_VOICE] + output_format = config[CONF_OUTPUT_FORMAT] + + return WatsonTTSProvider( + service, supported_languages, default_voice, output_format) + + +class WatsonTTSProvider(Provider): + """IBM Watson TTS api provider.""" + + def __init__(self, + service, + supported_languages, + default_voice, + output_format): + """Initialize Watson TTS provider.""" + self.service = service + self.supported_langs = supported_languages + self.default_lang = default_voice[:5] + self.default_voice = default_voice + self.output_format = output_format + self.name = 'Watson TTS' + + @property + def supported_languages(self): + """Return a list of supported languages.""" + return self.supported_langs + + @property + def default_language(self): + """Return the default language.""" + return self.default_lang + + @property + def default_options(self): + """Return dict include default options.""" + return {CONF_VOICE: self.default_voice} + + @property + def supported_options(self): + """Return a list of supported options.""" + return [CONF_VOICE] + + def get_tts_audio(self, message, language=None, options=None): + """Request TTS file from Watson TTS.""" + response = self.service.synthesize( + message, accept=self.output_format, + voice=self.default_voice).get_result() + + return (CONTENT_TYPE_EXTENSIONS[self.output_format], + response.content) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 84178beef8b..b6a4185abfd 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -120,17 +120,21 @@ async def handle_call_service(hass, connection, msg): msg['domain'], msg['service'], msg.get('service_data'), blocking, connection.context(msg)) connection.send_message(messages.result_message(msg['id'])) - except ServiceNotFound: - connection.send_message(messages.error_message( - msg['id'], const.ERR_NOT_FOUND, 'Service not found.')) + except ServiceNotFound as err: + if err.domain == msg['domain'] and err.service == msg['service']: + connection.send_message(messages.error_message( + msg['id'], const.ERR_NOT_FOUND, 'Service not found.')) + else: + connection.send_message(messages.error_message( + msg['id'], const.ERR_HOME_ASSISTANT_ERROR, str(err))) except HomeAssistantError as err: connection.logger.exception(err) connection.send_message(messages.error_message( - msg['id'], const.ERR_HOME_ASSISTANT_ERROR, '{}'.format(err))) + msg['id'], const.ERR_HOME_ASSISTANT_ERROR, str(err))) except Exception as err: # pylint: disable=broad-except connection.logger.exception(err) connection.send_message(messages.error_message( - msg['id'], const.ERR_UNKNOWN_ERROR, '{}'.format(err))) + msg['id'], const.ERR_UNKNOWN_ERROR, str(err))) @callback diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index c09e8c4c6e2..1aa1efc0eca 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -36,6 +36,13 @@ class ActiveConnection: """Send a result message.""" self.send_message(messages.result_message(msg_id, result)) + async def send_big_result(self, msg_id, result): + """Send a result message that would be expensive to JSON serialize.""" + content = await self.hass.async_add_executor_job( + const.JSON_DUMP, messages.result_message(msg_id, result) + ) + self.send_message(content) + @callback def send_error(self, msg_id, code, message): """Send a error message.""" diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index 53ca680c4c9..9c776e3b949 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -1,6 +1,9 @@ """Websocket constants.""" import asyncio from concurrent import futures +from functools import partial +import json +from homeassistant.helpers.json import JSONEncoder DOMAIN = 'websocket_api' URL = '/api/websocket' @@ -27,3 +30,5 @@ SIGNAL_WEBSOCKET_DISCONNECTED = 'websocket_disconnected' # Data used to store the current connection list DATA_CONNECTIONS = DOMAIN + '.connections' + +JSON_DUMP = partial(json.dumps, cls=JSONEncoder, allow_nan=False) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 85051dcae73..b652f38ee2f 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -1,8 +1,6 @@ """View to accept incoming websocket connection.""" import asyncio from contextlib import suppress -from functools import partial -import json import logging from aiohttp import web, WSMsgType @@ -11,18 +9,15 @@ import async_timeout from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback from homeassistant.components.http import HomeAssistantView -from homeassistant.helpers.json import JSONEncoder from .const import ( MAX_PENDING_MSG, CANCELLATION_ERRORS, URL, ERR_UNKNOWN_ERROR, SIGNAL_WEBSOCKET_CONNECTED, SIGNAL_WEBSOCKET_DISCONNECTED, - DATA_CONNECTIONS) + DATA_CONNECTIONS, JSON_DUMP) from .auth import AuthPhase, auth_required_message from .error import Disconnect from .messages import error_message -JSON_DUMP = partial(json.dumps, cls=JSONEncoder, allow_nan=False) - class WebsocketAPIView(HomeAssistantView): """View to serve a websockets endpoint.""" @@ -45,7 +40,7 @@ class WebSocketHandler: self.hass = hass self.request = request self.wsock = None - self._to_write = asyncio.Queue(maxsize=MAX_PENDING_MSG, loop=hass.loop) + self._to_write = asyncio.Queue(maxsize=MAX_PENDING_MSG) self._handle_task = None self._writer_task = None self._logger = logging.getLogger( @@ -62,7 +57,10 @@ class WebSocketHandler: break self._logger.debug("Sending %s", message) try: - await self.wsock.send_json(message, dumps=JSON_DUMP) + if isinstance(message, str): + await self.wsock.send_str(message) + else: + await self.wsock.send_json(message, dumps=JSON_DUMP) except (ValueError, TypeError) as err: self._logger.error('Unable to serialize to JSON: %s\n%s', err, message) @@ -103,7 +101,7 @@ class WebSocketHandler: # pylint: disable=no-member self._handle_task = asyncio.current_task() else: - self._handle_task = asyncio.Task.current_task(loop=self.hass.loop) + self._handle_task = asyncio.Task.current_task() @callback def handle_hass_stop(event): diff --git a/homeassistant/components/websocket_api/permissions.py b/homeassistant/components/websocket_api/permissions.py index f1849fda539..887573f4abb 100644 --- a/homeassistant/components/websocket_api/permissions.py +++ b/homeassistant/components/websocket_api/permissions.py @@ -10,14 +10,17 @@ from homeassistant.const import ( EVENT_THEMES_UPDATED) from homeassistant.components.persistent_notification import ( EVENT_PERSISTENT_NOTIFICATIONS_UPDATED) +from homeassistant.components.lovelace import EVENT_LOVELACE_UPDATED from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED +from homeassistant.components.frontend import EVENT_PANELS_UPDATED # These are events that do not contain any sensitive data # Except for state_changed, which is handled accordingly. SUBSCRIBE_WHITELIST = { EVENT_COMPONENT_LOADED, + EVENT_PANELS_UPDATED, EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, @@ -26,4 +29,5 @@ SUBSCRIBE_WHITELIST = { EVENT_AREA_REGISTRY_UPDATED, EVENT_DEVICE_REGISTRY_UPDATED, EVENT_ENTITY_REGISTRY_UPDATED, + EVENT_LOVELACE_UPDATED, } diff --git a/homeassistant/components/wink/manifest.json b/homeassistant/components/wink/manifest.json index c837a46e011..118f7a19733 100644 --- a/homeassistant/components/wink/manifest.json +++ b/homeassistant/components/wink/manifest.json @@ -3,7 +3,7 @@ "name": "Wink", "documentation": "https://www.home-assistant.io/components/wink", "requirements": [ - "pubnubsub-handler==1.0.4", + "pubnubsub-handler==1.0.6", "python-wink==1.10.5" ], "dependencies": ["configurator"], diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index fa4fcc96c12..668e7240372 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -88,7 +88,7 @@ class WorxLandroidSensor(Entity): try: session = async_get_clientsession(self.hass) - with async_timeout.timeout(self.timeout, loop=self.hass.loop): + with async_timeout.timeout(self.timeout): auth = aiohttp.helpers.BasicAuth('admin', self.pin) mower_response = await session.get(self.url, auth=auth) except (asyncio.TimeoutError, aiohttp.ClientError): diff --git a/homeassistant/components/wunderground/sensor.py b/homeassistant/components/wunderground/sensor.py index 7ad1a6fd75a..23fc02288c4 100644 --- a/homeassistant/components/wunderground/sensor.py +++ b/homeassistant/components/wunderground/sensor.py @@ -807,7 +807,7 @@ class WUndergroundData: async def async_update(self): """Get the latest data from WUnderground.""" try: - with async_timeout.timeout(10, loop=self._hass.loop): + with async_timeout.timeout(10): response = await self._session.get(self._build_url()) result = await response.json() if "error" in result['response']: diff --git a/homeassistant/components/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py index e541936ef0e..224c620e8ed 100644 --- a/homeassistant/components/xiaomi/camera.py +++ b/homeassistant/components/xiaomi/camera.py @@ -140,7 +140,7 @@ class XiaomiCamera(Camera): ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) self._last_image = await asyncio.shield(ffmpeg.get_image( url, output_format=IMAGE_JPEG, - extra_cmd=self._extra_arguments), loop=self.hass.loop) + extra_cmd=self._extra_arguments)) self._last_url = url return self._last_image diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index 22a8ec95c33..2ae69e3b58c 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -294,11 +294,16 @@ class XiaomiDevice(Entity): def parse_voltage(self, data): """Parse battery level data sent by gateway.""" - if 'voltage' not in data: + if 'voltage' in data: + voltage_key = 'voltage' + elif 'battery_voltage' in data: + voltage_key = 'battery_voltage' + else: return False + max_volt = 3300 min_volt = 2800 - voltage = data['voltage'] + voltage = data[voltage_key] voltage = min(voltage, max_volt) voltage = max(voltage, min_volt) percent = ((voltage - min_volt) / (max_volt - min_volt)) * 100 diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index ea00cd6d95e..01c896c1f75 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -467,7 +467,7 @@ async def async_setup_platform(hass, config, async_add_entities, update_tasks.append(device.async_update_ha_state(True)) if update_tasks: - await asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks) for air_purifier_service in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[air_purifier_service].get( diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index fa853d1f83d..951e3db511f 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -203,7 +203,7 @@ async def async_setup_platform(hass, config, async_add_entities, update_tasks.append(target_device.async_update_ha_state(True)) if update_tasks: - await asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks) for xiaomi_miio_service in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[xiaomi_miio_service].get( diff --git a/homeassistant/components/xiaomi_miio/remote.py b/homeassistant/components/xiaomi_miio/remote.py index 7cb0cd68439..6a78766801a 100644 --- a/homeassistant/components/xiaomi_miio/remote.py +++ b/homeassistant/components/xiaomi_miio/remote.py @@ -142,7 +142,7 @@ async def async_setup_platform(hass, config, async_add_entities, message['error']['message'] == "learn timeout"): await hass.async_add_executor_job(device.learn, slot) - await asyncio.sleep(1, loop=hass.loop) + await asyncio.sleep(1) _LOGGER.error("Timeout. No infrared command captured") hass.components.persistent_notification.async_create( diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 91924c82821..1c3752c54c8 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -185,7 +185,7 @@ async def async_setup_platform(hass, config, async_add_entities, update_tasks.append(device.async_update_ha_state(True)) if update_tasks: - await asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks) for plug_service in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[plug_service].get('schema', SERVICE_SCHEMA) diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index ce527d41e25..c44a9e3fba3 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -165,7 +165,7 @@ async def async_setup_platform(hass, config, async_add_entities, update_tasks.append(update_coro) if update_tasks: - await asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks) for vacuum_service in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[vacuum_service].get( diff --git a/homeassistant/components/xiaomi_tv/manifest.json b/homeassistant/components/xiaomi_tv/manifest.json index 221532c1c8d..26940a57c78 100644 --- a/homeassistant/components/xiaomi_tv/manifest.json +++ b/homeassistant/components/xiaomi_tv/manifest.json @@ -7,6 +7,6 @@ ], "dependencies": [], "codeowners": [ - "@fattdev" + "@simse" ] } diff --git a/homeassistant/components/yandextts/tts.py b/homeassistant/components/yandextts/tts.py index e08f44d1974..89bf4e98c52 100644 --- a/homeassistant/components/yandextts/tts.py +++ b/homeassistant/components/yandextts/tts.py @@ -111,7 +111,7 @@ class YandexSpeechKitProvider(Provider): options = options or {} try: - with async_timeout.timeout(10, loop=self.hass.loop): + with async_timeout.timeout(10): url_param = { 'text': message, 'lang': actual_language, diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index dd89ed27f53..dabd66751fd 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -4,6 +4,7 @@ import logging from datetime import timedelta import voluptuous as vol +from yeelight import Bulb, BulbException from homeassistant.components.discovery import SERVICE_YEELIGHT from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_SCAN_INTERVAL, \ CONF_HOST, ATTR_ENTITY_ID @@ -184,7 +185,6 @@ class YeelightDevice: def bulb(self): """Return bulb device.""" if self._bulb_device is None: - from yeelight import Bulb, BulbException try: self._bulb_device = Bulb(self._ipaddr, model=self._model) # force init for type @@ -238,8 +238,6 @@ class YeelightDevice: def turn_on(self, duration=DEFAULT_TRANSITION, light_type=None): """Turn on device.""" - from yeelight import BulbException - try: self.bulb.turn_on(duration=duration, light_type=light_type) except BulbException as ex: @@ -248,8 +246,6 @@ class YeelightDevice: def turn_off(self, duration=DEFAULT_TRANSITION, light_type=None): """Turn off device.""" - from yeelight import BulbException - try: self.bulb.turn_off(duration=duration, light_type=light_type) except BulbException as ex: @@ -258,8 +254,6 @@ class YeelightDevice: def update(self): """Read new properties from the device.""" - from yeelight import BulbException - if not self.bulb: return diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 8d48e695b31..33116d973e9 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -2,6 +2,8 @@ import logging import voluptuous as vol +from yeelight import (RGBTransition, SleepTransition, Flow, BulbException) +from yeelight.enums import PowerMode, LightType, BulbType from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service import extract_entity_ids from homeassistant.util.color import ( @@ -92,8 +94,6 @@ def _transitions_config_parser(transitions): def _parse_custom_effects(effects_config): - from yeelight import Flow - effects = {} for config in effects_config: params = config[CONF_FLOW_PARAMS] @@ -113,7 +113,6 @@ def _parse_custom_effects(effects_config): def _cmd(func): """Define a wrapper to catch exceptions from the bulb.""" def _wrap(self, *args, **kwargs): - from yeelight import BulbException try: _LOGGER.debug("Calling %s with %s %s", func, args, kwargs) return func(self, *args, **kwargs) @@ -125,8 +124,6 @@ def _cmd(func): def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Yeelight bulbs.""" - from yeelight.enums import PowerMode - data_key = '{}_lights'.format(DATA_YEELIGHT) if not discovery_info: @@ -187,8 +184,6 @@ class YeelightLight(Light): def __init__(self, device, custom_effects=None): """Initialize the Yeelight light.""" - from yeelight.enums import LightType - self.config = device.config self._device = device @@ -347,12 +342,11 @@ class YeelightLight(Light): def update(self) -> None: """Update properties from the bulb.""" - from yeelight import BulbType, enums bulb_type = self._bulb.bulb_type if bulb_type == BulbType.Color: self._supported_features = SUPPORT_YEELIGHT_RGB - elif self.light_type == enums.LightType.Ambient: + 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: @@ -423,8 +417,6 @@ class YeelightLight(Light): def set_flash(self, flash) -> None: """Activate flash.""" if flash: - from yeelight import (RGBTransition, SleepTransition, Flow, - BulbException) if self._bulb.last_properties["color_mode"] != 1: _LOGGER.error("Flash supported currently only in RGB mode.") return @@ -458,7 +450,6 @@ class YeelightLight(Light): def set_effect(self, effect) -> None: """Activate effect.""" if effect: - from yeelight import (Flow, BulbException) from yeelight.transitions import (disco, temp, strobe, pulse, strobe_color, alarm, police, police2, christmas, rgb, @@ -502,7 +493,6 @@ class YeelightLight(Light): def turn_on(self, **kwargs) -> None: """Turn the bulb on.""" - import yeelight brightness = kwargs.get(ATTR_BRIGHTNESS) colortemp = kwargs.get(ATTR_COLOR_TEMP) hs_color = kwargs.get(ATTR_HS_COLOR) @@ -519,7 +509,7 @@ class YeelightLight(Light): if self.config[CONF_MODE_MUSIC] and not self._bulb.music_mode: try: self.set_music_mode(self.config[CONF_MODE_MUSIC]) - except yeelight.BulbException as ex: + except BulbException as ex: _LOGGER.error("Unable to turn on music mode," "consider disabling it: %s", ex) @@ -530,7 +520,7 @@ class YeelightLight(Light): self.set_brightness(brightness, duration) self.set_flash(flash) self.set_effect(effect) - except yeelight.BulbException as ex: + except BulbException as ex: _LOGGER.error("Unable to set bulb properties: %s", ex) return @@ -540,7 +530,7 @@ class YeelightLight(Light): or rgb): try: self.set_default() - except yeelight.BulbException as ex: + except BulbException as ex: _LOGGER.error("Unable to set the defaults: %s", ex) return self.device.update() @@ -556,27 +546,23 @@ class YeelightLight(Light): def set_mode(self, mode: str): """Set a power mode.""" - import yeelight - try: - self._bulb.set_power_mode(yeelight.enums.PowerMode[mode.upper()]) + self._bulb.set_power_mode(PowerMode[mode.upper()]) self.device.update() - except yeelight.BulbException as ex: + except BulbException as ex: _LOGGER.error("Unable to set the power mode: %s", ex) def start_flow(self, transitions, count=0, action=ACTION_RECOVER): """Start flow.""" - import yeelight - try: - flow = yeelight.Flow( + flow = Flow( count=count, - action=yeelight.Flow.actions[action], + action=Flow.actions[action], transitions=transitions) self._bulb.start_flow(flow, light_type=self.light_type) self.device.update() - except yeelight.BulbException as ex: + except BulbException as ex: _LOGGER.error("Unable to set effect: %s", ex) @@ -590,8 +576,6 @@ class YeelightAmbientLight(YeelightLight): def __init__(self, *args, **kwargs): """Initialize the Yeelight Ambient light.""" - from yeelight.enums import LightType - super().__init__(*args, **kwargs) self._min_mireds = kelvin_to_mired(6500) self._max_mireds = kelvin_to_mired(1700) diff --git a/homeassistant/components/yi/camera.py b/homeassistant/components/yi/camera.py index 0dbb42c384e..5ee2f2d9b58 100644 --- a/homeassistant/components/yi/camera.py +++ b/homeassistant/components/yi/camera.py @@ -77,7 +77,7 @@ class YiCamera(Camera): """Retrieve the latest video file from the customized Yi FTP server.""" from aioftp import Client, StatusCodeError - ftp = Client(loop=self.hass.loop) + ftp = Client() try: await ftp.connect(self.host) await ftp.login(self.user, self.passwd) diff --git a/homeassistant/components/yr/sensor.py b/homeassistant/components/yr/sensor.py index c9f57abf5d9..8a28fe42f89 100644 --- a/homeassistant/components/yr/sensor.py +++ b/homeassistant/components/yr/sensor.py @@ -160,7 +160,7 @@ class YrData: async_call_later(self.hass, minutes*60, self.fetching_data) try: websession = async_get_clientsession(self.hass) - with async_timeout.timeout(10, loop=self.hass.loop): + with async_timeout.timeout(10): resp = await websession.get( self._url, params=self._urlparams) if resp.status != 200: @@ -247,4 +247,4 @@ class YrData: tasks.append(dev.async_update_ha_state()) if tasks: - await asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index d2dcd907885..289aba6ef56 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -1,18 +1,32 @@ """Support for exposing Home Assistant via Zeroconf.""" +# PyLint bug confuses absolute/relative imports +# https://github.com/PyCQA/pylint/issues/1931 +# pylint: disable=no-name-in-module import logging import socket +import ipaddress import voluptuous as vol +from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf + from homeassistant import util from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__) +from homeassistant.generated.zeroconf import ZEROCONF, HOMEKIT _LOGGER = logging.getLogger(__name__) DOMAIN = 'zeroconf' +ATTR_HOST = 'host' +ATTR_PORT = 'port' +ATTR_HOSTNAME = 'hostname' +ATTR_TYPE = 'type' +ATTR_NAME = 'name' +ATTR_PROPERTIES = 'properties' ZEROCONF_TYPE = '_home-assistant._tcp.local.' +HOMEKIT_TYPE = '_hap._tcp.local.' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({}), @@ -21,10 +35,6 @@ CONFIG_SCHEMA = vol.Schema({ def setup(hass, config): """Set up Zeroconf and make Home Assistant discoverable.""" - from zeroconf import Zeroconf, ServiceInfo - - zeroconf = Zeroconf() - zeroconf_name = '{}.{}'.format(hass.config.location_name, ZEROCONF_TYPE) params = { @@ -41,12 +51,41 @@ def setup(hass, config): except socket.error: host_ip_pton = socket.inet_pton(socket.AF_INET6, host_ip) - info = ServiceInfo(ZEROCONF_TYPE, zeroconf_name, host_ip_pton, - hass.http.server_port, 0, 0, params) + info = ServiceInfo(ZEROCONF_TYPE, zeroconf_name, None, + addresses=[host_ip_pton], port=hass.http.server_port, + properties=params) + + zeroconf = Zeroconf() zeroconf.register_service(info) - def stop_zeroconf(event): + def service_update(zeroconf, service_type, name, state_change): + """Service state changed.""" + if state_change != ServiceStateChange.Added: + return + + service_info = zeroconf.get_service_info(service_type, name) + info = info_from_service(service_info) + _LOGGER.debug("Discovered new device %s %s", name, info) + + # If we can handle it as a HomeKit discovery, we do that here. + if service_type == HOMEKIT_TYPE and handle_homekit(hass, info): + return + + for domain in ZEROCONF[service_type]: + hass.add_job( + hass.config_entries.flow.async_init( + domain, context={'source': DOMAIN}, data=info + ) + ) + + for service in ZEROCONF: + ServiceBrowser(zeroconf, service, handlers=[service_update]) + + if HOMEKIT_TYPE not in ZEROCONF: + ServiceBrowser(zeroconf, HOMEKIT_TYPE, handlers=[service_update]) + + def stop_zeroconf(_): """Stop Zeroconf.""" zeroconf.unregister_service(info) zeroconf.close() @@ -54,3 +93,59 @@ def setup(hass, config): hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_zeroconf) return True + + +def handle_homekit(hass, info) -> bool: + """Handle a HomeKit discovery. + + Return if discovery was forwarded. + """ + model = None + props = info.get('properties', {}) + + for key in props: + if key.lower() == 'md': + model = props[key] + break + + if model is None: + return False + + for test_model in HOMEKIT: + if not model.startswith(test_model): + continue + + hass.add_job( + hass.config_entries.flow.async_init( + HOMEKIT[test_model], context={'source': 'homekit'}, data=info + ) + ) + return True + + return False + + +def info_from_service(service): + """Return prepared info from mDNS entries.""" + properties = {} + + for key, value in service.properties.items(): + try: + if isinstance(value, bytes): + value = value.decode('utf-8') + properties[key.decode('utf-8')] = value + except UnicodeDecodeError: + _LOGGER.warning("Unicode decode error on %s: %s", key, value) + + address = service.addresses[0] + + info = { + ATTR_HOST: str(ipaddress.ip_address(address)), + ATTR_PORT: service.port, + ATTR_HOSTNAME: service.server, + ATTR_TYPE: service.type, + ATTR_NAME: service.name, + ATTR_PROPERTIES: properties, + } + + return info diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 7ef9b250363..1461a54d147 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -3,12 +3,13 @@ "name": "Zeroconf", "documentation": "https://www.home-assistant.io/components/zeroconf", "requirements": [ - "zeroconf==0.22.0" + "zeroconf==0.23.0" ], "dependencies": [ "api" ], "codeowners": [ - "@robbiet480" + "@robbiet480", + "@Kane610" ] } diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 10467b20cfa..d48ecd8467c 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -75,7 +75,7 @@ class ZestimateDataSensor(Entity): @property def name(self): """Return the name of the sensor.""" - return self._name + return '{} {}'.format(self._name, self.address) @property def state(self): diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 8395c2317e8..c3aa0e50f44 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -246,6 +246,7 @@ class Light(ZhaEntity, light.Light): async def async_get_state(self, from_cache=True): """Attempt to retrieve on off state from the light.""" + self.debug("polling current state") if self._on_off_channel: self._state = await self._on_off_channel.get_attribute_value( 'on_off', from_cache=from_cache) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 9f1f69a11ec..610498e6237 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -1,6 +1,7 @@ { "domain": "zha", "name": "Zigbee Home Automation", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/zha", "requirements": [ "bellows-homeassistant==0.7.3", diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 242f0362088..0340964561c 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -3,15 +3,19 @@ import logging import voluptuous as vol +from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv from homeassistant.const import ( CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, CONF_RADIUS) from homeassistant.helpers import config_per_platform from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.util import slugify +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.util.location import distance + from .config_flow import configured_zones -from .const import CONF_PASSIVE, DOMAIN, HOME_ZONE +from .const import CONF_PASSIVE, DOMAIN, HOME_ZONE, ATTR_PASSIVE, ATTR_RADIUS from .zone import Zone _LOGGER = logging.getLogger(__name__) @@ -37,6 +41,40 @@ PLATFORM_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +@bind_hass +def async_active_zone(hass, latitude, longitude, radius=0): + """Find the active zone for given latitude, longitude. + + This method must be run in the event loop. + """ + # Sort entity IDs so that we are deterministic if equal distance to 2 zones + zones = (hass.states.get(entity_id) for entity_id + in sorted(hass.states.async_entity_ids(DOMAIN))) + + min_dist = None + closest = None + + for zone in zones: + if zone.attributes.get(ATTR_PASSIVE): + continue + + zone_dist = distance( + latitude, longitude, + zone.attributes[ATTR_LATITUDE], zone.attributes[ATTR_LONGITUDE]) + + within_zone = zone_dist - radius < zone.attributes[ATTR_RADIUS] + closer_zone = closest is None or zone_dist < min_dist + smaller_zone = (zone_dist == min_dist and + zone.attributes[ATTR_RADIUS] < + closest.attributes[ATTR_RADIUS]) + + if within_zone and (closer_zone or smaller_zone): + min_dist = zone_dist + closest = zone + + return closest + + async def async_setup(hass, config): """Set up configured zones as well as home assistant zone if necessary.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/zone/const.py b/homeassistant/components/zone/const.py index b69ba67302a..676104b6943 100644 --- a/homeassistant/components/zone/const.py +++ b/homeassistant/components/zone/const.py @@ -3,3 +3,5 @@ CONF_PASSIVE = 'passive' DOMAIN = 'zone' HOME_ZONE = 'home' +ATTR_PASSIVE = 'passive' +ATTR_RADIUS = 'radius' diff --git a/homeassistant/components/zone/manifest.json b/homeassistant/components/zone/manifest.json index 897908b61da..e9281fec3f7 100644 --- a/homeassistant/components/zone/manifest.json +++ b/homeassistant/components/zone/manifest.json @@ -1,6 +1,7 @@ { "domain": "zone", "name": "Zone", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/zone", "requirements": [], "dependencies": [], diff --git a/homeassistant/components/zone/zone.py b/homeassistant/components/zone/zone.py index 21084e18f06..20155e06311 100644 --- a/homeassistant/components/zone/zone.py +++ b/homeassistant/components/zone/zone.py @@ -1,60 +1,13 @@ """Zone entity and functionality.""" from homeassistant.const import ATTR_HIDDEN, ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.helpers.entity import Entity -from homeassistant.loader import bind_hass -from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.location import distance -from .const import DOMAIN - -ATTR_PASSIVE = 'passive' -ATTR_RADIUS = 'radius' +from .const import ATTR_PASSIVE, ATTR_RADIUS STATE = 'zoning' -@bind_hass -def active_zone(hass, latitude, longitude, radius=0): - """Find the active zone for given latitude, longitude.""" - return run_callback_threadsafe( - hass.loop, async_active_zone, hass, latitude, longitude, radius - ).result() - - -@bind_hass -def async_active_zone(hass, latitude, longitude, radius=0): - """Find the active zone for given latitude, longitude. - - This method must be run in the event loop. - """ - # Sort entity IDs so that we are deterministic if equal distance to 2 zones - zones = (hass.states.get(entity_id) for entity_id - in sorted(hass.states.async_entity_ids(DOMAIN))) - - min_dist = None - closest = None - - for zone in zones: - if zone.attributes.get(ATTR_PASSIVE): - continue - - zone_dist = distance( - latitude, longitude, - zone.attributes[ATTR_LATITUDE], zone.attributes[ATTR_LONGITUDE]) - - within_zone = zone_dist - radius < zone.attributes[ATTR_RADIUS] - closer_zone = closest is None or zone_dist < min_dist - smaller_zone = (zone_dist == min_dist and - zone.attributes[ATTR_RADIUS] < - closest.attributes[ATTR_RADIUS]) - - if within_zone and (closer_zone or smaller_zone): - min_dist = zone_dist - closest = zone - - return closest - - def in_zone(zone, latitude, longitude, radius=0) -> bool: """Test if given latitude, longitude is in given zone. diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 741c6f852a8..51e956e3314 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -376,6 +376,25 @@ async def async_setup_entry(hass, config_entry): hass.add_job(check_has_unique_id, entity, _on_ready, _on_timeout, hass.loop) + def node_removed(node): + node_id = node.node_id + node_key = 'node-{}'.format(node_id) + _LOGGER.info("Node Removed: %s", + hass.data[DATA_DEVICES][node_key]) + for key in list(hass.data[DATA_DEVICES]): + if not key.startswith('{}-'.format(node_id)): + continue + + entity = hass.data[DATA_DEVICES][key] + _LOGGER.info('Removing Entity - value: %s - entity_id: %s', + key, entity.entity_id) + hass.add_job(entity.node_removed()) + del hass.data[DATA_DEVICES][key] + + entity = hass.data[DATA_DEVICES][node_key] + hass.add_job(entity.node_removed()) + del hass.data[DATA_DEVICES][node_key] + def network_ready(): """Handle the query of all awake nodes.""" _LOGGER.info("Z-Wave network is ready for use. All awake nodes " @@ -399,6 +418,8 @@ async def async_setup_entry(hass, config_entry): value_added, ZWaveNetwork.SIGNAL_VALUE_ADDED, weak=False) dispatcher.connect( node_added, ZWaveNetwork.SIGNAL_NODE_ADDED, weak=False) + dispatcher.connect( + node_removed, ZWaveNetwork.SIGNAL_NODE_REMOVED, weak=False) dispatcher.connect( network_ready, ZWaveNetwork.SIGNAL_AWAKE_NODES_QUERIED, weak=False) dispatcher.connect( @@ -694,7 +715,7 @@ async def async_setup_entry(hass, config_entry): network.state_str) break else: - await asyncio.sleep(1, loop=hass.loop) + await asyncio.sleep(1) hass.async_add_job(_finalize_start) diff --git a/homeassistant/components/zwave/manifest.json b/homeassistant/components/zwave/manifest.json index 598af58ad17..f88945fa281 100644 --- a/homeassistant/components/zwave/manifest.json +++ b/homeassistant/components/zwave/manifest.json @@ -1,6 +1,7 @@ { "domain": "zwave", "name": "Z-Wave", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/zwave", "requirements": [ "homeassistant-pyozw==0.1.4", diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 2339b8aba36..0a24f888c20 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -3,6 +3,7 @@ import logging from homeassistant.core import callback from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_WAKEUP, ATTR_ENTITY_ID +from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.helpers.entity import Entity from .const import ( @@ -74,6 +75,16 @@ class ZWaveBaseEntity(Entity): if self.hass and self.platform: self.hass.add_job(_async_remove_and_add) + async def node_removed(self): + """Call when a node is removed from the Z-Wave network.""" + await self.async_remove() + + registry = await async_get_registry(self.hass) + if self.entity_id not in registry.entities: + return + + registry.async_remove(self.entity_id) + class ZWaveNodeEntity(ZWaveBaseEntity): """Representation of a Z-Wave node.""" diff --git a/homeassistant/config.py b/homeassistant/config.py index 1be3ba082e8..7e8bcec08a5 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 LooseVersion # pylint: disable=import-error +from distutils.version import StrictVersion # pylint: disable=import-error import logging import os import re @@ -18,19 +18,21 @@ from homeassistant.auth import providers as auth_providers,\ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ASSUMED_STATE, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_PACKAGES, CONF_UNIT_SYSTEM, - CONF_TIME_ZONE, CONF_ELEVATION, CONF_UNIT_SYSTEM_METRIC, + CONF_TIME_ZONE, CONF_ELEVATION, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, TEMP_CELSIUS, __version__, CONF_CUSTOMIZE, CONF_CUSTOMIZE_DOMAIN, CONF_CUSTOMIZE_GLOB, CONF_WHITELIST_EXTERNAL_DIRS, CONF_AUTH_PROVIDERS, CONF_AUTH_MFA_MODULES, CONF_TYPE, CONF_ID) -from homeassistant.core import callback, DOMAIN as CONF_CORE, HomeAssistant +from homeassistant.core import ( + DOMAIN as CONF_CORE, SOURCE_YAML, HomeAssistant, + callback) from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import ( Integration, async_get_integration, IntegrationNotFound ) from homeassistant.util.yaml import load_yaml, SECRET_YAML +from homeassistant.util.package import is_docker_env import homeassistant.helpers.config_validation as cv -from homeassistant.util import dt as date_util, location as loc_util from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM from homeassistant.helpers.entity_values import EntityValues from homeassistant.helpers import config_per_platform, extract_domain_configs @@ -50,22 +52,6 @@ FILE_MIGRATION = ( ('ios.conf', '.ios.conf'), ) -DEFAULT_CORE_CONFIG = ( - # Tuples (attribute, default, auto detect property, description) - (CONF_NAME, 'Home', None, 'Name of the location where Home Assistant is ' - 'running'), - (CONF_LATITUDE, 0, 'latitude', 'Location required to calculate the time' - ' the sun rises and sets'), - (CONF_LONGITUDE, 0, 'longitude', None), - (CONF_ELEVATION, 0, None, 'Impacts weather/sunrise data' - ' (altitude above sea level in meters)'), - (CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_METRIC, None, - '{} for Metric, {} for Imperial'.format(CONF_UNIT_SYSTEM_METRIC, - CONF_UNIT_SYSTEM_IMPERIAL)), - (CONF_TIME_ZONE, 'UTC', 'time_zone', 'Pick yours from here: http://en.wiki' - 'pedia.org/wiki/List_of_tz_database_time_zones'), - (CONF_CUSTOMIZE, '!include customize.yaml', None, 'Customization file'), -) # type: Tuple[Tuple[str, Any, Any, Optional[str]], ...] DEFAULT_CONFIG = """ # Configure a default setup of Home Assistant (frontend, api, etc) default_config: @@ -74,9 +60,6 @@ default_config: # http: # base_url: example.duckdns.org:8123 -# Discover some devices automatically -discovery: - # Sensors sensor: # Weather prediction @@ -205,8 +188,7 @@ def get_default_config_dir() -> str: return os.path.join(data_dir, CONFIG_DIR_NAME) # type: ignore -async def async_ensure_config_exists(hass: HomeAssistant, config_dir: str, - detect_location: bool = True)\ +async def async_ensure_config_exists(hass: HomeAssistant, config_dir: str) \ -> Optional[str]: """Ensure a configuration file exists in given configuration directory. @@ -218,49 +200,22 @@ async def async_ensure_config_exists(hass: HomeAssistant, config_dir: str, if config_path is None: print("Unable to find configuration. Creating default one in", config_dir) - config_path = await async_create_default_config( - hass, config_dir, detect_location) + config_path = await async_create_default_config(hass, config_dir) return config_path -async def async_create_default_config( - hass: HomeAssistant, config_dir: str, detect_location: bool = True - ) -> Optional[str]: +async def async_create_default_config(hass: HomeAssistant, config_dir: str) \ + -> Optional[str]: """Create a default configuration file in given configuration directory. Return path to new config file if success, None if failed. This method needs to run in an executor. """ - info = {attr: default for attr, default, _, _ in DEFAULT_CORE_CONFIG} - - if detect_location: - session = hass.helpers.aiohttp_client.async_get_clientsession() - location_info = await loc_util.async_detect_location_info(session) - else: - location_info = None - - if location_info: - if location_info.use_metric: - info[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_METRIC - else: - info[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_IMPERIAL - - for attr, default, prop, _ in DEFAULT_CORE_CONFIG: - if prop is None: - continue - info[attr] = getattr(location_info, prop) or default - - if location_info.latitude and location_info.longitude: - info[CONF_ELEVATION] = await loc_util.async_get_elevation( - session, location_info.latitude, location_info.longitude) - - return await hass.async_add_executor_job( - _write_default_config, config_dir, info - ) + return await hass.async_add_executor_job(_write_default_config, config_dir) -def _write_default_config(config_dir: str, info: Dict)\ +def _write_default_config(config_dir: str)\ -> Optional[str]: """Write the default config.""" from homeassistant.components.config.group import ( @@ -269,8 +224,6 @@ def _write_default_config(config_dir: str, info: Dict)\ CONFIG_PATH as AUTOMATION_CONFIG_PATH) from homeassistant.components.config.script import ( CONFIG_PATH as SCRIPT_CONFIG_PATH) - from homeassistant.components.config.customize import ( - CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) config_path = os.path.join(config_dir, YAML_CONFIG_FILE) secret_path = os.path.join(config_dir, SECRET_YAML) @@ -278,21 +231,11 @@ def _write_default_config(config_dir: str, info: Dict)\ group_yaml_path = os.path.join(config_dir, GROUP_CONFIG_PATH) automation_yaml_path = os.path.join(config_dir, AUTOMATION_CONFIG_PATH) script_yaml_path = os.path.join(config_dir, SCRIPT_CONFIG_PATH) - customize_yaml_path = os.path.join(config_dir, CUSTOMIZE_CONFIG_PATH) # Writing files with YAML does not create the most human readable results # So we're hard coding a YAML template. try: with open(config_path, 'wt') as config_file: - config_file.write("homeassistant:\n") - - for attr, _, _, description in DEFAULT_CORE_CONFIG: - if info[attr] is None: - continue - elif description: - config_file.write(" # {}\n".format(description)) - config_file.write(" {}: {}\n".format(attr, info[attr])) - config_file.write(DEFAULT_CONFIG) with open(secret_path, 'wt') as secret_file: @@ -310,9 +253,6 @@ def _write_default_config(config_dir: str, info: Dict)\ with open(script_yaml_path, 'wt'): pass - with open(customize_yaml_path, 'wt'): - pass - return config_path except IOError: @@ -356,13 +296,11 @@ def find_config_file(config_dir: Optional[str]) -> Optional[str]: def load_yaml_config_file(config_path: str) -> Dict[Any, Any]: """Parse a YAML configuration file. + Raises FileNotFoundError or HomeAssistantError. + This method needs to run in an executor. """ - try: - conf_dict = load_yaml(config_path) - except FileNotFoundError as err: - raise HomeAssistantError("Config file not found: {}".format( - getattr(err, 'filename', err))) + conf_dict = load_yaml(config_path) if not isinstance(conf_dict, dict): msg = "The configuration file {} does not contain a dictionary".format( @@ -396,13 +334,15 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: _LOGGER.info("Upgrading configuration directory from %s to %s", conf_version, __version__) - if LooseVersion(conf_version) < LooseVersion('0.50'): + version_obj = StrictVersion(conf_version) + + if version_obj < StrictVersion('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 LooseVersion(conf_version) < LooseVersion('0.92'): + if version_obj < StrictVersion('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 @@ -420,6 +360,13 @@ 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(): + # In 0.94 we no longer install packages inside the deps folder when + # running inside a Docker container. + lib_path = hass.config.path('deps') + if os.path.isdir(lib_path): + shutil.rmtree(lib_path) + with open(version_path, 'wt') as outp: outp.write(__version__) @@ -511,20 +458,14 @@ async def async_process_ha_core_config( auth_conf, mfa_conf)) + await hass.config.async_load() + hac = hass.config - def set_time_zone(time_zone_str: Optional[str]) -> None: - """Help to set the time zone.""" - if time_zone_str is None: - return - - time_zone = date_util.get_time_zone(time_zone_str) - - if time_zone: - hac.time_zone = time_zone - date_util.set_default_time_zone(time_zone) - else: - _LOGGER.error("Received invalid time zone %s", time_zone_str) + if any([k in config for k in [ + CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_ELEVATION, + CONF_TIME_ZONE, CONF_UNIT_SYSTEM]]): + hac.config_source = SOURCE_YAML for key, attr in ((CONF_LATITUDE, 'latitude'), (CONF_LONGITUDE, 'longitude'), @@ -533,7 +474,8 @@ async def async_process_ha_core_config( if key in config: setattr(hac, attr, config[key]) - set_time_zone(config.get(CONF_TIME_ZONE)) + if CONF_TIME_ZONE in config: + hac.set_time_zone(config[CONF_TIME_ZONE]) # Init whitelist external dir hac.whitelist_external_dirs = {hass.config.path('www')} @@ -581,54 +523,6 @@ async def async_process_ha_core_config( "with '%s: %s'", CONF_TEMPERATURE_UNIT, unit, CONF_UNIT_SYSTEM, hac.units.name) - # Shortcut if no auto-detection necessary - if None not in (hac.latitude, hac.longitude, hac.units, - hac.time_zone, hac.elevation): - return - - discovered = [] # type: List[Tuple[str, Any]] - - # If we miss some of the needed values, auto detect them - if None in (hac.latitude, hac.longitude, hac.units, - hac.time_zone): - info = await loc_util.async_detect_location_info( - hass.helpers.aiohttp_client.async_get_clientsession() - ) - - if info is None: - _LOGGER.error("Could not detect location information") - return - - if hac.latitude is None and hac.longitude is None: - hac.latitude, hac.longitude = (info.latitude, info.longitude) - discovered.append(('latitude', hac.latitude)) - discovered.append(('longitude', hac.longitude)) - - if hac.units is None: - hac.units = METRIC_SYSTEM if info.use_metric else IMPERIAL_SYSTEM - discovered.append((CONF_UNIT_SYSTEM, hac.units.name)) - - if hac.location_name is None: - hac.location_name = info.city - discovered.append(('name', info.city)) - - if hac.time_zone is None: - set_time_zone(info.time_zone) - discovered.append(('time_zone', info.time_zone)) - - if hac.elevation is None and hac.latitude is not None and \ - hac.longitude is not None: - elevation = await loc_util.async_get_elevation( - hass.helpers.aiohttp_client.async_get_clientsession(), - hac.latitude, hac.longitude) - hac.elevation = elevation - discovered.append(('elevation', elevation)) - - if discovered: - _LOGGER.warning( - "Incomplete core configuration. Auto detected %s", - ", ".join('{}: {}'.format(key, val) for key, val in discovered)) - def _log_pkg_error( package: str, component: str, config: Dict, message: str) -> None: @@ -770,7 +664,11 @@ async def async_process_component_config( This method must be run in the event loop. """ domain = integration.domain - component = integration.get_component() + try: + component = integration.get_component() + except ImportError as ex: + _LOGGER.error("Unable to import %s: %s", domain, ex) + return None if hasattr(component, 'CONFIG_SCHEMA'): try: @@ -823,14 +721,22 @@ async def async_process_component_config( # Create a copy of the configuration with all config for current # component removed and add validated config back in. - filter_keys = extract_domain_configs(config, domain) - config = {key: value for key, value in config.items() - if key not in filter_keys} + config = config_without_domain(config, domain) config[domain] = platforms return config +@callback +def config_without_domain(config: Dict, domain: str) -> Dict: + """Return a config with all configuration for a domain removed.""" + filter_keys = extract_domain_configs(config, domain) + return { + key: value for key, value in config.items() + if key not in filter_keys + } + + async def async_check_ha_config_file(hass: HomeAssistant) -> Optional[str]: """Check if Home Assistant configuration file is valid. diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a2b34a00efd..299bfe9b407 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -140,55 +140,6 @@ SOURCE_DISCOVERY = 'discovery' SOURCE_IMPORT = 'import' HANDLERS = Registry() -# Components that have config flows. In future we will auto-generate this list. -FLOWS = [ - 'ambiclimate', - 'ambient_station', - 'axis', - 'cast', - 'daikin', - 'deconz', - 'dialogflow', - 'esphome', - 'emulated_roku', - 'geofency', - 'gpslogger', - 'hangouts', - 'heos', - 'homematicip_cloud', - 'hue', - 'ifttt', - 'ios', - 'ipma', - 'lifx', - 'locative', - 'logi_circle', - 'luftdaten', - 'mailgun', - 'mobile_app', - 'mqtt', - 'nest', - 'openuv', - 'owntracks', - 'point', - 'ps4', - 'rainmachine', - 'simplisafe', - 'smartthings', - 'smhi', - 'sonos', - 'tellduslive', - 'toon', - 'tplink', - 'tradfri', - 'twilio', - 'unifi', - 'upnp', - 'zha', - 'zone', - 'zwave', -] - STORAGE_KEY = 'core.config_entries' STORAGE_VERSION = 1 @@ -218,6 +169,8 @@ UNRECOVERABLE_STATES = ( DISCOVERY_NOTIFICATION_ID = 'config_entry_discovery' DISCOVERY_SOURCES = ( + 'ssdp', + 'zeroconf', SOURCE_DISCOVERY, SOURCE_IMPORT, ) @@ -297,7 +250,17 @@ class ConfigEntry: if integration is None: integration = await loader.async_get_integration(hass, self.domain) - component = integration.get_component() + try: + component = integration.get_component() + if self.domain == integration.domain: + integration.get_platform('config_flow') + except ImportError as err: + _LOGGER.error( + 'Error importing integration %s to set up %s config entry: %s', + integration.domain, self.domain, err) + if self.domain == integration.domain: + self.state = ENTRY_STATE_SETUP_ERROR + return # Perform migration if integration.domain == self.domain: @@ -420,7 +383,8 @@ class ConfigEntry: if self.version == handler.VERSION: return True - component = getattr(hass.components, self.domain) + integration = await loader.async_get_integration(hass, self.domain) + component = integration.get_component() supports_migrate = hasattr(component, 'async_migrate_entry') if not supports_migrate: _LOGGER.error("Migration handler not found for entry %s for %s", @@ -428,7 +392,9 @@ class ConfigEntry: return False try: - result = await component.async_migrate_entry(hass, self) + result = await component.async_migrate_entry( # type: ignore + hass, self + ) if not isinstance(result, bool): _LOGGER.error('%s.async_migrate_entry did not return boolean', self.domain) @@ -439,7 +405,7 @@ class ConfigEntry: return result except Exception: # pylint: disable=broad-except _LOGGER.exception('Error migrating entry %s for %s', - self.title, component.DOMAIN) + self.title, self.domain) return False def add_update_listener(self, listener: Callable) -> Callable: @@ -712,10 +678,10 @@ class ConfigEntries: self.hass, self._hass_config, integration) try: - integration.get_component() + integration.get_platform('config_flow') except ImportError as err: _LOGGER.error( - 'Error occurred while loading integration %s: %s', + 'Error occurred loading config flow for integration %s: %s', handler_key, err) raise data_entry_flow.UnknownHandler diff --git a/homeassistant/const.py b/homeassistant/const.py index 458b90d8203..58897e78d0c 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 = 93 -PATCH_VERSION = '2' +MINOR_VERSION = 94 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) @@ -160,21 +160,23 @@ CONF_XY = 'xy' CONF_ZONE = 'zone' # #### EVENTS #### +EVENT_AUTOMATION_TRIGGERED = 'automation_triggered' +EVENT_CALL_SERVICE = 'call_service' +EVENT_COMPONENT_LOADED = 'component_loaded' +EVENT_CORE_CONFIG_UPDATE = 'core_config_updated' +EVENT_HOMEASSISTANT_CLOSE = 'homeassistant_close' EVENT_HOMEASSISTANT_START = 'homeassistant_start' EVENT_HOMEASSISTANT_STOP = 'homeassistant_stop' -EVENT_HOMEASSISTANT_CLOSE = 'homeassistant_close' -EVENT_STATE_CHANGED = 'state_changed' -EVENT_TIME_CHANGED = 'time_changed' -EVENT_CALL_SERVICE = 'call_service' +EVENT_LOGBOOK_ENTRY = 'logbook_entry' EVENT_PLATFORM_DISCOVERED = 'platform_discovered' -EVENT_COMPONENT_LOADED = 'component_loaded' +EVENT_SCRIPT_STARTED = 'script_started' EVENT_SERVICE_REGISTERED = 'service_registered' EVENT_SERVICE_REMOVED = 'service_removed' -EVENT_LOGBOOK_ENTRY = 'logbook_entry' +EVENT_STATE_CHANGED = 'state_changed' EVENT_THEMES_UPDATED = 'themes_updated' EVENT_TIMER_OUT_OF_SYNC = 'timer_out_of_sync' -EVENT_AUTOMATION_TRIGGERED = 'automation_triggered' -EVENT_SCRIPT_STARTED = 'script_started' +EVENT_TIME_CHANGED = 'time_changed' + # #### DEVICE CLASSES #### DEVICE_CLASS_BATTERY = 'battery' diff --git a/homeassistant/core.py b/homeassistant/core.py index c127e100f11..b732eb0d4b3 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -27,12 +27,12 @@ import attr import voluptuous as vol from homeassistant.const import ( - ATTR_DOMAIN, ATTR_FRIENDLY_NAME, ATTR_NOW, ATTR_SERVICE, - ATTR_SERVICE_DATA, ATTR_SECONDS, EVENT_CALL_SERVICE, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REMOVED, - EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED, - EVENT_TIME_CHANGED, EVENT_TIMER_OUT_OF_SYNC, MATCH_ALL, __version__) + ATTR_DOMAIN, ATTR_FRIENDLY_NAME, ATTR_NOW, ATTR_SERVICE, ATTR_SERVICE_DATA, + ATTR_SECONDS, CONF_UNIT_SYSTEM_IMPERIAL, EVENT_CALL_SERVICE, + EVENT_CORE_CONFIG_UPDATE, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REMOVED, + EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, + EVENT_TIMER_OUT_OF_SYNC, MATCH_ALL, __version__) from homeassistant import loader from homeassistant.exceptions import ( HomeAssistantError, InvalidEntityFormatError, InvalidStateError, @@ -43,7 +43,8 @@ from homeassistant.util.async_ import ( from homeassistant import util import homeassistant.util.dt as dt_util from homeassistant.util import location, slugify -from homeassistant.util.unit_system import UnitSystem, METRIC_SYSTEM # NOQA +from homeassistant.util.unit_system import ( # NOQA + UnitSystem, IMPERIAL_SYSTEM, METRIC_SYSTEM) # Typing imports that create a circular dependency # pylint: disable=using-constant-test @@ -56,11 +57,19 @@ CALLABLE_T = TypeVar('CALLABLE_T', bound=Callable) CALLBACK_TYPE = Callable[[], None] # pylint: enable=invalid-name +CORE_STORAGE_KEY = 'core.config' +CORE_STORAGE_VERSION = 1 + DOMAIN = 'homeassistant' # How long we wait for the result of a service call SERVICE_CALL_LIMIT = 10 # seconds +# Source of core configuration +SOURCE_DISCOVERED = 'discovered' +SOURCE_STORAGE = 'storage' +SOURCE_YAML = 'yaml' + # How long to wait till things that run on startup have to finish. TIMEOUT_EVENT_START = 15 @@ -144,7 +153,7 @@ class HomeAssistant: self.bus = EventBus(self) self.services = ServiceRegistry(self) self.states = StateMachine(self.bus, self.loop) - self.config = Config() # type: Config + self.config = Config(self) # type: Config self.components = loader.Components(self) self.helpers = loader.Helpers(self) # This is a dictionary that any component can store any data on. @@ -337,7 +346,7 @@ class HomeAssistant: def block_till_done(self) -> None: """Block till all pending work is done.""" run_coroutine_threadsafe( - self.async_block_till_done(), loop=self.loop).result() + self.async_block_till_done(), self.loop).result() async def async_block_till_done(self) -> None: """Block till all pending work is done.""" @@ -1168,15 +1177,19 @@ class ServiceRegistry: class Config: """Configuration settings for Home Assistant.""" - def __init__(self) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize a new config object.""" - self.latitude = None # type: Optional[float] - self.longitude = None # type: Optional[float] - self.elevation = None # type: Optional[int] - self.location_name = None # type: Optional[str] - self.time_zone = None # type: Optional[datetime.tzinfo] + self.hass = hass + + self.latitude = 0 # type: float + self.longitude = 0 # type: float + self.elevation = 0 # type: int + self.location_name = "Home" # type: str + self.time_zone = dt_util.UTC # type: datetime.tzinfo self.units = METRIC_SYSTEM # type: UnitSystem + self.config_source = "default" # type: str + # If True, pip install is skipped for requirements on startup self.skip_pip = False # type: bool @@ -1233,7 +1246,7 @@ class Config: return False def as_dict(self) -> Dict: - """Create a dictionary representation of this dict. + """Create a dictionary representation of the configuration. Async friendly. """ @@ -1251,9 +1264,91 @@ class Config: 'components': self.components, 'config_dir': self.config_dir, 'whitelist_external_dirs': self.whitelist_external_dirs, - 'version': __version__ + 'version': __version__, + 'config_source': self.config_source } + def set_time_zone(self, time_zone_str: str) -> None: + """Help to set the time zone.""" + time_zone = dt_util.get_time_zone(time_zone_str) + + if time_zone: + self.time_zone = time_zone + dt_util.set_default_time_zone(time_zone) + else: + raise ValueError( + "Received invalid time zone {}".format(time_zone_str)) + + @callback + def _update(self, *, + source: str, + latitude: Optional[float] = None, + longitude: Optional[float] = None, + elevation: Optional[int] = None, + unit_system: Optional[str] = None, + location_name: Optional[str] = None, + time_zone: Optional[str] = None) -> None: + """Update the configuration from a dictionary. + + Async friendly. + """ + self.config_source = source + if latitude is not None: + self.latitude = latitude + if longitude is not None: + self.longitude = longitude + if elevation is not None: + self.elevation = elevation + if unit_system is not None: + if unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + self.units = IMPERIAL_SYSTEM + else: + self.units = METRIC_SYSTEM + if location_name is not None: + self.location_name = location_name + if time_zone is not None: + self.set_time_zone(time_zone) + + async def update(self, **kwargs: Any) -> None: + """Update the configuration from a dictionary. + + Async friendly. + """ + self._update(source=SOURCE_STORAGE, **kwargs) + await self.async_store() + self.hass.bus.async_fire( + EVENT_CORE_CONFIG_UPDATE, kwargs + ) + + async def async_load(self) -> None: + """Load [homeassistant] core config.""" + store = self.hass.helpers.storage.Store( + CORE_STORAGE_VERSION, CORE_STORAGE_KEY, private=True) + data = await store.async_load() + if not data: + return + + self._update(source=SOURCE_STORAGE, **data) + + async def async_store(self) -> None: + """Store [homeassistant] core config.""" + time_zone = dt_util.UTC.zone + if self.time_zone and getattr(self.time_zone, 'zone'): + time_zone = getattr(self.time_zone, 'zone') + + data = { + 'latitude': self.latitude, + 'longitude': self.longitude, + 'elevation': self.elevation, + 'unit_system': self.units.name, + 'location_name': self.location_name, + 'time_zone': time_zone, + } + + store = self.hass.helpers.storage.Store( + CORE_STORAGE_VERSION, CORE_STORAGE_KEY, private=True) + await store.async_save(data) + def _async_create_timer(hass: HomeAssistant) -> None: """Create a timer that will start on HOMEASSISTANT_START.""" diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index acd0befda4e..389b8498421 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -11,6 +11,11 @@ _LOGGER = logging.getLogger(__name__) RESULT_TYPE_FORM = 'form' RESULT_TYPE_CREATE_ENTRY = 'create_entry' RESULT_TYPE_ABORT = 'abort' +RESULT_TYPE_EXTERNAL_STEP = 'external' +RESULT_TYPE_EXTERNAL_STEP_DONE = 'external_done' + +# Event that is fired when a flow is progressed via external source. +EVENT_DATA_ENTRY_FLOW_PROGRESSED = 'data_entry_flow_progressed' class FlowError(HomeAssistantError): @@ -53,6 +58,8 @@ class FlowManager: context: Optional[Dict] = None, data: Any = None) -> Any: """Start a configuration flow.""" + if context is None: + context = {} flow = await self._async_create_flow( handler, context=context, data=data) flow.hass = self.hass @@ -71,13 +78,31 @@ class FlowManager: if flow is None: raise UnknownFlow - step_id, data_schema = flow.cur_step + cur_step = flow.cur_step - if data_schema is not None and user_input is not None: - user_input = data_schema(user_input) + if cur_step.get('data_schema') is not None and user_input is not None: + user_input = cur_step['data_schema'](user_input) - return await self._async_handle_step( - flow, step_id, user_input) + result = await self._async_handle_step( + flow, cur_step['step_id'], user_input) + + if cur_step['type'] == RESULT_TYPE_EXTERNAL_STEP: + if result['type'] not in (RESULT_TYPE_EXTERNAL_STEP, + RESULT_TYPE_EXTERNAL_STEP_DONE): + raise ValueError("External step can only transition to " + "external step or external step done.") + + # If the result has changed from last result, fire event to update + # the frontend. + if cur_step['step_id'] != result.get('step_id'): + # Tell frontend to reload the flow state. + self.hass.bus.async_fire(EVENT_DATA_ENTRY_FLOW_PROGRESSED, { + 'handler': flow.handler, + 'flow_id': flow_id, + 'refresh': True + }) + + return result @callback def async_abort(self, flow_id: str) -> None: @@ -97,13 +122,15 @@ class FlowManager: result = await getattr(flow, method)(user_input) # type: Dict - if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_CREATE_ENTRY, - RESULT_TYPE_ABORT): + if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_EXTERNAL_STEP, + RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_ABORT, + RESULT_TYPE_EXTERNAL_STEP_DONE): raise ValueError( 'Handler returned incorrect type: {}'.format(result['type'])) - if result['type'] == RESULT_TYPE_FORM: - flow.cur_step = (result['step_id'], result['data_schema']) + if result['type'] in (RESULT_TYPE_FORM, RESULT_TYPE_EXTERNAL_STEP, + RESULT_TYPE_EXTERNAL_STEP_DONE): + flow.cur_step = result return result # We pass a copy of the result because we're mutating our version @@ -111,7 +138,7 @@ class FlowManager: # _async_finish_flow may change result type, check it again if result['type'] == RESULT_TYPE_FORM: - flow.cur_step = (result['step_id'], result['data_schema']) + flow.cur_step = result return result # Abort and Success results both finish the flow @@ -180,3 +207,27 @@ class FlowHandler: 'reason': reason, 'description_placeholders': description_placeholders, } + + @callback + def async_external_step(self, *, step_id: str, url: str, + description_placeholders: Optional[Dict] = None) \ + -> Dict: + """Return the definition of an external step for the user to take.""" + return { + 'type': RESULT_TYPE_EXTERNAL_STEP, + 'flow_id': self.flow_id, + 'handler': self.handler, + 'step_id': step_id, + 'url': url, + 'description_placeholders': description_placeholders, + } + + @callback + def async_external_step_done(self, *, next_step_id: str) -> Dict: + """Return the definition of an external step for the user to take.""" + return { + 'type': RESULT_TYPE_EXTERNAL_STEP_DONE, + 'flow_id': self.flow_id, + 'handler': self.handler, + 'step_id': next_step_id, + } diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index aadee3e792b..6a44af9943b 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -75,3 +75,7 @@ class ServiceNotFound(HomeAssistantError): self, "Service {}.{} not found".format(domain, service)) self.domain = domain self.service = service + + def __str__(self) -> str: + """Return string representation.""" + return "Unable to find service {}/{}".format(self.domain, self.service) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py new file mode 100644 index 00000000000..c9a8c593b27 --- /dev/null +++ b/homeassistant/generated/config_flows.py @@ -0,0 +1,55 @@ +"""Automatically generated by hassfest. + +To update, run python3 -m hassfest +""" + + +FLOWS = [ + "ambiclimate", + "ambient_station", + "axis", + "cast", + "daikin", + "deconz", + "dialogflow", + "emulated_roku", + "esphome", + "geofency", + "gpslogger", + "hangouts", + "heos", + "homekit_controller", + "homematicip_cloud", + "hue", + "ifttt", + "ios", + "ipma", + "iqvia", + "lifx", + "locative", + "logi_circle", + "luftdaten", + "mailgun", + "mobile_app", + "mqtt", + "nest", + "openuv", + "owntracks", + "point", + "ps4", + "rainmachine", + "simplisafe", + "smartthings", + "smhi", + "sonos", + "tellduslive", + "toon", + "tplink", + "tradfri", + "twilio", + "unifi", + "upnp", + "zha", + "zone", + "zwave" +] diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py new file mode 100644 index 00000000000..cc1d286bf5f --- /dev/null +++ b/homeassistant/generated/ssdp.py @@ -0,0 +1,16 @@ +"""Automatically generated by hassfest. + +To update, run python3 -m hassfest +""" + + +SSDP = { + "device_type": {}, + "manufacturer": { + "Royal Philips Electronics": [ + "deconz", + "hue" + ] + }, + "st": {} +} diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py new file mode 100644 index 00000000000..024bb89dc99 --- /dev/null +++ b/homeassistant/generated/zeroconf.py @@ -0,0 +1,24 @@ +"""Automatically generated by hassfest. + +To update, run python3 -m hassfest +""" + + +ZEROCONF = { + "_axis-video._tcp.local.": [ + "axis" + ], + "_coap._udp.local.": [ + "tradfri" + ], + "_esphomelib._tcp.local.": [ + "esphome" + ], + "_hap._tcp.local.": [ + "homekit_controller" + ] +} + +HOMEKIT = { + "LIFX ": "lifx" +} diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index f5b3e443d3a..ff2ce8b7d98 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -80,7 +80,7 @@ async def async_aiohttp_proxy_web( timeout: int = 10) -> Optional[web.StreamResponse]: """Stream websession request to aiohttp web response.""" try: - with async_timeout.timeout(timeout, loop=hass.loop): + with async_timeout.timeout(timeout): req = await web_coro except asyncio.CancelledError: @@ -120,7 +120,7 @@ async def async_aiohttp_proxy_stream(hass: HomeAssistantType, try: while True: - with async_timeout.timeout(timeout, loop=hass.loop): + with async_timeout.timeout(timeout): data = await stream.read(buffer_size) if not data: diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 6d200a39c85..c3e5195131b 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -81,6 +81,10 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow): return await self.async_step_confirm() + async_step_zeroconf = async_step_discovery + async_step_ssdp = async_step_discovery + async_step_homekit = async_step_discovery + async def async_step_import(self, _): """Handle a flow initialized by import.""" if self._async_in_progress() or self._async_current_entries(): diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 378febf8f6d..d3ac4763269 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -58,7 +58,7 @@ class FlowManagerIndexView(_BaseFlowManagerView): except data_entry_flow.UnknownHandler: return self.json_message('Invalid handler specified', 404) except data_entry_flow.UnknownStep: - return self.json_message('Handler does not support init', 400) + return self.json_message('Handler does not support user', 400) result = self._prepare_result_json(result) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 5c066967437..d090e571a8b 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -219,6 +219,14 @@ class DeviceRegistry: return new + def _async_remove_device(self, device_id): + del self.devices[device_id] + self.hass.bus.async_fire(EVENT_DEVICE_REGISTRY_UPDATED, { + 'action': 'remove', + 'device_id': device_id, + }) + self.async_schedule_save() + async def async_load(self): """Load the device registry.""" data = await self._store.async_load() @@ -278,10 +286,15 @@ class DeviceRegistry: @callback def async_clear_config_entry(self, config_entry_id): """Clear config entry from registry entries.""" + remove = [] for dev_id, device in self.devices.items(): - if config_entry_id in device.config_entries: + if device.config_entries == {config_entry_id}: + remove.append(dev_id) + else: self._async_update_device( dev_id, remove_config_entry_id=config_entry_id) + for dev_id in remove: + self._async_remove_device(dev_id) @callback def async_clear_area_id(self, area_id: str) -> None: diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 5a5f3dc8177..fb31e664605 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -109,7 +109,7 @@ class EntityComponent: tasks.append(self._async_setup_platform(p_type, p_config)) if tasks: - await asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks) # Generic discovery listener for loading platform dynamically # Refer to: homeassistant.components.discovery.load_platform() @@ -250,7 +250,7 @@ class EntityComponent: in self._platforms.values()] if tasks: - await asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks) self._platforms = { self.domain: self._platforms[self.domain] diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index a092c89405e..30868c33f9d 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -45,7 +45,7 @@ class EntityPlatform: self._async_unsub_polling = None # Method to cancel the retry of setup self._async_cancel_retry_setup = None - self._process_updates = asyncio.Lock(loop=hass.loop) + self._process_updates = None # Platform is None for the EntityComponent "catch-all" EntityPlatform # which powers entity_component.add_entities @@ -122,8 +122,8 @@ class EntityPlatform: task = async_create_setup_task() await asyncio.wait_for( - asyncio.shield(task, loop=hass.loop), - SLOW_SETUP_MAX_WAIT, loop=hass.loop) + asyncio.shield(task), + SLOW_SETUP_MAX_WAIT) # Block till all entities are done if self._tasks: @@ -132,7 +132,7 @@ class EntityPlatform: if pending: await asyncio.wait( - pending, loop=self.hass.loop) + pending) hass.config.components.add(full_name) return True @@ -218,7 +218,7 @@ class EntityPlatform: if not tasks: return - await asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks) self.async_entities_added_callback() if self._async_unsub_polling is not None or \ @@ -379,7 +379,7 @@ class EntityPlatform: tasks = [self.async_remove_entity(entity_id) for entity_id in self.entities] - await asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks) if self._async_unsub_polling is not None: self._async_unsub_polling() @@ -404,6 +404,8 @@ class EntityPlatform: This method must be run in the event loop. """ + if self._process_updates is None: + self._process_updates = asyncio.Lock() if self._process_updates.locked(): self.logger.warning( "Updating %s %s took longer than the scheduled update " @@ -419,4 +421,4 @@ class EntityPlatform: tasks.append(entity.async_update_ha_state(True)) if tasks: - await asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 0a0c441b9cf..2fb32d5214e 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -302,9 +302,11 @@ class EntityRegistry: @callback def async_clear_config_entry(self, config_entry): """Clear config entry from registry entries.""" - for entity_id, entry in self.entities.items(): - if config_entry == entry.config_entry_id: - self._async_update_entity(entity_id, config_entry_id=None) + for entity_id in [ + entity_id + for entity_id, entry in self.entities.items() + if config_entry == entry.config_entry_id]: + self.async_remove(entity_id) @bind_hass diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index 7db577dfdc6..590aba02670 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -1,5 +1,5 @@ """Helper class to implement include/exclude of entities and domains.""" -from typing import Callable, Dict, Iterable +from typing import Callable, Dict, List import voluptuous as vol @@ -12,7 +12,7 @@ CONF_EXCLUDE_DOMAINS = 'exclude_domains' CONF_EXCLUDE_ENTITIES = 'exclude_entities' -def _convert_filter(config: Dict[str, Iterable[str]]) -> Callable[[str], bool]: +def _convert_filter(config: Dict[str, List[str]]) -> Callable[[str], bool]: filt = generate_filter( config[CONF_INCLUDE_DOMAINS], config[CONF_INCLUDE_ENTITIES], @@ -20,6 +20,8 @@ def _convert_filter(config: Dict[str, Iterable[str]]) -> Callable[[str], bool]: config[CONF_EXCLUDE_ENTITIES], ) setattr(filt, 'config', config) + setattr( + filt, 'empty_filter', sum(len(val) for val in config.values()) == 0) return filt @@ -34,10 +36,10 @@ FILTER_SCHEMA = vol.All( }), _convert_filter) -def generate_filter(include_domains: Iterable[str], - include_entities: Iterable[str], - exclude_domains: Iterable[str], - exclude_entities: Iterable[str]) -> Callable[[str], bool]: +def generate_filter(include_domains: List[str], + include_entities: List[str], + exclude_domains: List[str], + exclude_entities: List[str]) -> Callable[[str], bool]: """Return a function that will filter entities based on the args.""" include_d = set(include_domains) include_e = set(include_entities) diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index bbed1ffbbcd..992ba6c10cc 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -209,7 +209,7 @@ async def async_reproduce_state_legacy( ) if domain_tasks: - await asyncio.wait(domain_tasks, loop=hass.loop) + await asyncio.wait(domain_tasks) def state_as_number(state: State) -> float: diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 5fbb7700458..67ce2f7a923 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -57,7 +57,7 @@ class Store: self._data = None self._unsub_delay_listener = None self._unsub_stop_listener = None - self._write_lock = asyncio.Lock(loop=hass.loop) + self._write_lock = asyncio.Lock() self._load_task = None self._encoder = encoder diff --git a/homeassistant/helpers/sun.py b/homeassistant/helpers/sun.py index 049359a7313..bcb84234b84 100644 --- a/homeassistant/helpers/sun.py +++ b/homeassistant/helpers/sun.py @@ -43,9 +43,18 @@ def get_astral_event_next( utc_point_in_time: Optional[datetime.datetime] = None, offset: Optional[datetime.timedelta] = None) -> datetime.datetime: """Calculate the next specified solar event.""" - from astral import AstralError - location = get_astral_location(hass) + return get_location_astral_event_next( + location, event, utc_point_in_time, offset) + + +@callback +def get_location_astral_event_next( + location: 'astral.Location', event: str, + utc_point_in_time: Optional[datetime.datetime] = None, + offset: Optional[datetime.timedelta] = None) -> datetime.datetime: + """Calculate the next specified solar event.""" + from astral import AstralError if offset is None: offset = datetime.timedelta() diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 4f655e692f7..f008551c0fa 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -2,9 +2,9 @@ import logging from typing import Any, Dict, Iterable, Optional -from homeassistant import config_entries from homeassistant.loader import async_get_integration, bind_hass from homeassistant.util.json import load_json +from homeassistant.generated import config_flows from .typing import HomeAssistantType _LOGGER = logging.getLogger(__name__) @@ -106,7 +106,7 @@ async def async_get_component_resources(hass: HomeAssistantType, translation_cache = hass.data[TRANSLATION_STRING_CACHE][language] # Get the set of components - components = hass.config.components | set(config_entries.FLOWS) + components = hass.config.components | set(config_flows.FLOWS) # Calculate the missing components missing_components = components - set(translation_cache) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a4a08af1236..8ae1023e1a9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,6 +4,7 @@ async_timeout==3.0.1 attrs==19.1.0 bcrypt==3.1.6 certifi>=2018.04.16 +importlib-metadata==0.15 jinja2>=2.10 PyJWT==1.7.1 cryptography==2.6.1 @@ -11,7 +12,7 @@ pip>=8.0.3 python-slugify==3.0.2 pytz>=2019.01 pyyaml>=3.13,<4 -requests==2.21.0 +requests==2.22.0 ruamel.yaml==0.15.94 voluptuous==0.11.5 voluptuous-serialize==2.1.0 diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index a3d168d22e7..2ab4fe28bdc 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -1,13 +1,9 @@ """Module to handle installing requirements.""" import asyncio -from functools import partial +from pathlib import Path import logging import os -import sys from typing import Any, Dict, List, Optional -from urllib.parse import urlparse - -import pkg_resources import homeassistant.util.package as pkg_util from homeassistant.core import HomeAssistant @@ -15,6 +11,7 @@ from homeassistant.core import HomeAssistant DATA_PIP_LOCK = 'pip_lock' DATA_PKG_CACHE = 'pkg_cache' CONSTRAINT_FILE = 'package_constraints.txt' +PROGRESS_FILE = '.pip_progress' _LOGGER = logging.getLogger(__name__) @@ -26,21 +23,18 @@ async def async_process_requirements(hass: HomeAssistant, name: str, """ pip_lock = hass.data.get(DATA_PIP_LOCK) if pip_lock is None: - pip_lock = hass.data[DATA_PIP_LOCK] = asyncio.Lock(loop=hass.loop) + pip_lock = hass.data[DATA_PIP_LOCK] = asyncio.Lock() - pkg_cache = hass.data.get(DATA_PKG_CACHE) - if pkg_cache is None: - pkg_cache = hass.data[DATA_PKG_CACHE] = PackageLoadable(hass) - - pip_install = partial(pkg_util.install_package, - **pip_kwargs(hass.config.config_dir)) + kwargs = pip_kwargs(hass.config.config_dir) async with pip_lock: for req in requirements: - if await pkg_cache.loadable(req): + if pkg_util.is_installed(req): continue - ret = await hass.async_add_executor_job(pip_install, req) + ret = await hass.async_add_executor_job( + _install, hass, req, kwargs + ) if not ret: _LOGGER.error("Not initializing %s because could not install " @@ -50,58 +44,27 @@ async def async_process_requirements(hass: HomeAssistant, name: str, return True +def _install(hass: HomeAssistant, req: str, kwargs: Dict) -> bool: + """Install requirement.""" + progress_path = Path(hass.config.path(PROGRESS_FILE)) + progress_path.touch() + try: + return pkg_util.install_package(req, **kwargs) + finally: + progress_path.unlink() + + def pip_kwargs(config_dir: Optional[str]) -> Dict[str, Any]: """Return keyword arguments for PIP install.""" + is_docker = pkg_util.is_docker_env() kwargs = { - 'constraints': os.path.join(os.path.dirname(__file__), CONSTRAINT_FILE) + 'constraints': os.path.join(os.path.dirname(__file__), + CONSTRAINT_FILE), + 'no_cache_dir': is_docker, } - if not (config_dir is None or pkg_util.is_virtual_env()): + if 'WHEELS_LINKS' in os.environ: + kwargs['find_links'] = os.environ['WHEELS_LINKS'] + if not (config_dir is None or pkg_util.is_virtual_env()) and \ + not is_docker: kwargs['target'] = os.path.join(config_dir, 'deps') return kwargs - - -class PackageLoadable: - """Class to check if a package is loadable, with built-in cache.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the PackageLoadable class.""" - self.dist_cache = {} # type: Dict[str, pkg_resources.Distribution] - self.hass = hass - - async def loadable(self, package: str) -> bool: - """Check if a package is what will be loaded when we import it. - - Returns True when the requirement is met. - Returns False when the package is not installed or doesn't meet req. - """ - dist_cache = self.dist_cache - - try: - req = pkg_resources.Requirement.parse(package) - except ValueError: - # This is a zip file. We no longer use this in Home Assistant, - # leaving it in for custom components. - req = pkg_resources.Requirement.parse(urlparse(package).fragment) - - req_proj_name = req.project_name.lower() - dist = dist_cache.get(req_proj_name) - - if dist is not None: - return dist in req - - for path in sys.path: - # We read the whole mount point as we're already here - # Caching it on first call makes subsequent calls a lot faster. - await self.hass.async_add_executor_job(self._fill_cache, path) - - dist = dist_cache.get(req_proj_name) - if dist is not None: - return dist in req - - return False - - def _fill_cache(self, path: str) -> None: - """Add packages from a path to the cache.""" - dist_cache = self.dist_cache - for dist in pkg_resources.find_distributions(path): - dist_cache.setdefault(dist.project_name.lower(), dist) diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index 070d907a7d9..961ce5a9d13 100644 --- a/homeassistant/scripts/__init__.py +++ b/homeassistant/scripts/__init__.py @@ -9,9 +9,9 @@ from typing import List from homeassistant.bootstrap import async_mount_local_lib_path from homeassistant.config import get_default_config_dir -from homeassistant.core import HomeAssistant -from homeassistant.requirements import pip_kwargs, PackageLoadable -from homeassistant.util.package import install_package, is_virtual_env +from homeassistant.requirements import pip_kwargs +from homeassistant.util.package import ( + install_package, is_virtual_env, is_installed) def run(args: List) -> int: @@ -49,10 +49,8 @@ def run(args: List) -> int: logging.basicConfig(stream=sys.stdout, level=logging.INFO) - hass = HomeAssistant(loop) - pkgload = PackageLoadable(hass) for req in getattr(script, 'REQUIREMENTS', []): - if loop.run_until_complete(pkgload.loadable(req)): + if is_installed(req): continue if not install_package(req, **_pip_kwargs): diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index e231d7602cd..d159f7dcedd 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -54,7 +54,7 @@ async def async_million_events(hass): """Run a million events.""" count = 0 event_name = 'benchmark_event' - event = asyncio.Event(loop=hass.loop) + event = asyncio.Event() @core.callback def listener(_): @@ -81,7 +81,7 @@ async def async_million_events(hass): async def async_million_time_changed_helper(hass): """Run a million events through time changed helper.""" count = 0 - event = asyncio.Event(loop=hass.loop) + event = asyncio.Event() @core.callback def listener(_): @@ -112,7 +112,7 @@ async def async_million_state_changed_helper(hass): """Run a million events through state changed helper.""" count = 0 entity_id = 'light.kitchen' - event = asyncio.Event(loop=hass.loop) + event = asyncio.Event() @core.callback def listener(*args): diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 9a273845887..991a45b6498 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -17,7 +17,8 @@ from homeassistant.config import ( CONF_PACKAGES, merge_packages_config, _format_config_error, find_config_file, load_yaml_config_file, extract_domain_configs, config_per_platform) -from homeassistant.util import yaml + +import homeassistant.util.yaml.loader as yaml_loader from homeassistant.exceptions import HomeAssistantError REQUIREMENTS = ('colorlog==4.0.2',) @@ -25,12 +26,14 @@ REQUIREMENTS = ('colorlog==4.0.2',) _LOGGER = logging.getLogger(__name__) # pylint: disable=protected-access MOCKS = { - 'load': ("homeassistant.util.yaml.load_yaml", yaml.load_yaml), - 'load*': ("homeassistant.config.load_yaml", yaml.load_yaml), - 'secrets': ("homeassistant.util.yaml.secret_yaml", yaml.secret_yaml), + 'load': ("homeassistant.util.yaml.loader.load_yaml", + yaml_loader.load_yaml), + 'load*': ("homeassistant.config.load_yaml", yaml_loader.load_yaml), + 'secrets': ("homeassistant.util.yaml.loader.secret_yaml", + yaml_loader.secret_yaml), } SILENCE = ( - 'homeassistant.scripts.check_config.yaml.clear_secret_cache', + 'homeassistant.scripts.check_config.yaml_loader.clear_secret_cache', ) PATCHES = {} @@ -195,7 +198,8 @@ def check(config_dir, secrets=False): if secrets: # Ensure !secrets point to the patched function - yaml.yaml.SafeLoader.add_constructor('!secret', yaml.secret_yaml) + yaml_loader.yaml.SafeLoader.add_constructor('!secret', + yaml_loader.secret_yaml) try: hass = core.HomeAssistant() @@ -203,7 +207,7 @@ def check(config_dir, secrets=False): res['components'] = hass.loop.run_until_complete( check_ha_config_file(hass)) - res['secret_cache'] = OrderedDict(yaml.__SECRET_CACHE) + res['secret_cache'] = OrderedDict(yaml_loader.__SECRET_CACHE) for err in res['components'].errors: domain = err.domain or ERROR_STR @@ -221,7 +225,8 @@ def check(config_dir, secrets=False): pat.stop() if secrets: # Ensure !secrets point to the original function - yaml.yaml.SafeLoader.add_constructor('!secret', yaml.secret_yaml) + yaml_loader.yaml.SafeLoader.add_constructor( + '!secret', yaml_loader.secret_yaml) bootstrap.clear_secret_cache() return res @@ -239,7 +244,7 @@ def line_info(obj, **kwargs): def dump_dict(layer, indent_count=3, listi=False, **kwargs): """Display a dict. - A friendly version of print yaml.yaml.dump(config). + A friendly version of print yaml_loader.yaml.dump(config). """ def sort_dict_key(val): """Return the dict key for sorting.""" @@ -307,11 +312,13 @@ async def check_ha_config_file(hass): return result.add_error("File configuration.yaml not found.") config = await hass.async_add_executor_job( load_yaml_config_file, config_path) + except FileNotFoundError: + return result.add_error("File not found: {}".format(config_path)) except HomeAssistantError as err: return result.add_error( "Error loading {}: {}".format(config_path, err)) finally: - yaml.clear_secret_cache() + yaml_loader.clear_secret_cache() # Extract and validate core [homeassistant] config try: diff --git a/homeassistant/setup.py b/homeassistant/setup.py index ee362ad130f..86a188bea01 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -27,7 +27,7 @@ def setup_component(hass: core.HomeAssistant, domain: str, config: Dict) -> bool: """Set up a component and all its dependencies.""" return run_coroutine_threadsafe( # type: ignore - async_setup_component(hass, domain, config), loop=hass.loop).result() + async_setup_component(hass, domain, config), hass.loop).result() async def async_setup_component(hass: core.HomeAssistant, domain: str, @@ -69,7 +69,7 @@ async def _async_process_dependencies( if not tasks: return True - results = await asyncio.gather(*tasks, loop=hass.loop) + results = await asyncio.gather(*tasks) failed = [dependencies[idx] for idx, res in enumerate(results) if not res] @@ -207,7 +207,7 @@ async def async_prepare_setup_platform(hass: core.HomeAssistant, def log_error(msg: str) -> None: """Log helper.""" _LOGGER.error("Unable to prepare setup for platform %s: %s", - platform_name, msg) + platform_path, msg) async_notify_setup_error(hass, platform_path) try: @@ -226,8 +226,8 @@ async def async_prepare_setup_platform(hass: core.HomeAssistant, try: platform = integration.get_platform(domain) - except ImportError: - log_error("Platform not found.") + except ImportError as exc: + log_error("Platform not found ({}).".format(exc)) return None # Already loaded @@ -239,8 +239,8 @@ async def async_prepare_setup_platform(hass: core.HomeAssistant, if integration.domain not in hass.config.components: try: component = integration.get_component() - except ImportError: - log_error("Unable to import the component") + except ImportError as exc: + log_error("Unable to import the component ({}).".format(exc)) return None if (hasattr(component, 'setup') diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index 1d13bcf0ce5..bacffa9da42 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -65,30 +65,6 @@ def distance(lat1: Optional[float], lon1: Optional[float], return result * 1000 -async def async_get_elevation(session: aiohttp.ClientSession, latitude: float, - longitude: float) -> int: - """Return elevation for given latitude and longitude.""" - try: - resp = await session.get(ELEVATION_URL, params={ - 'locations': '{},{}'.format(latitude, longitude), - }, timeout=5) - except (aiohttp.ClientError, asyncio.TimeoutError): - return 0 - - if resp.status != 200: - return 0 - - try: - raw_info = await resp.json() - except (aiohttp.ClientError, ValueError): - return 0 - - try: - return int(float(raw_info['results'][0]['elevation'])) - except (ValueError, KeyError, IndexError): - return 0 - - # Author: https://github.com/maurycyp # Source: https://github.com/maurycyp/vincenty # License: https://github.com/maurycyp/vincenty/blob/master/LICENSE diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index 317a30d9d56..a821c9b6fb8 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -64,7 +64,7 @@ class AsyncHandler: if blocking: while self._thread.is_alive(): - await asyncio.sleep(0, loop=self.loop) + await asyncio.sleep(0) def emit(self, record: Optional[logging.LogRecord]) -> None: """Process a record.""" diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 925755eb741..6f6d03d67b6 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -5,6 +5,12 @@ import os from subprocess import PIPE, Popen import sys from typing import Optional +from urllib.parse import urlparse +from pathlib import Path + +import pkg_resources +from importlib_metadata import version, PackageNotFoundError + _LOGGER = logging.getLogger(__name__) @@ -16,9 +22,35 @@ def is_virtual_env() -> bool: hasattr(sys, 'real_prefix')) +def is_docker_env() -> bool: + """Return True if we run in a docker env.""" + return Path("/.dockerenv").exists() + + +def is_installed(package: str) -> bool: + """Check if a package is installed and will be loaded when we import it. + + Returns True when the requirement is met. + Returns False when the package is not installed or doesn't meet req. + """ + try: + req = pkg_resources.Requirement.parse(package) + except ValueError: + # This is a zip file. We no longer use this in Home Assistant, + # leaving it in for custom components. + req = pkg_resources.Requirement.parse(urlparse(package).fragment) + + try: + return version(req.project_name) in req + except PackageNotFoundError: + return False + + def install_package(package: str, upgrade: bool = True, target: Optional[str] = None, - constraints: Optional[str] = None) -> bool: + constraints: Optional[str] = None, + find_links: Optional[str] = None, + no_cache_dir: Optional[bool] = False) -> bool: """Install a package on PyPi. Accepts pip compatible package strings. Return boolean if install successful. @@ -27,10 +59,14 @@ def install_package(package: str, upgrade: bool = True, _LOGGER.info('Attempting install of %s', package) env = os.environ.copy() args = [sys.executable, '-m', 'pip', 'install', '--quiet', package] + if no_cache_dir: + args.append('--no-cache-dir') if upgrade: args.append('--upgrade') if constraints is not None: args += ['--constraint', constraints] + if find_links is not None: + args += ['--find-links', find_links] if target: assert not is_virtual_env() # This only works if not running in venv diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py new file mode 100644 index 00000000000..da797a23074 --- /dev/null +++ b/homeassistant/util/yaml/__init__.py @@ -0,0 +1,15 @@ +"""YAML utility functions.""" +from .const import ( + SECRET_YAML, _SECRET_NAMESPACE +) +from .dumper import dump, save_yaml +from .loader import ( + clear_secret_cache, load_yaml, secret_yaml +) + + +__all__ = [ + 'SECRET_YAML', '_SECRET_NAMESPACE', + 'dump', 'save_yaml', + 'clear_secret_cache', 'load_yaml', 'secret_yaml', +] diff --git a/homeassistant/util/yaml/const.py b/homeassistant/util/yaml/const.py new file mode 100644 index 00000000000..9bd08d99326 --- /dev/null +++ b/homeassistant/util/yaml/const.py @@ -0,0 +1,4 @@ +"""Constants.""" +SECRET_YAML = 'secrets.yaml' + +_SECRET_NAMESPACE = 'homeassistant' diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py new file mode 100644 index 00000000000..d8f766c6c2b --- /dev/null +++ b/homeassistant/util/yaml/dumper.py @@ -0,0 +1,60 @@ +"""Custom dumper and representers.""" +from collections import OrderedDict +import yaml + +from .objects import NodeListClass + + +def dump(_dict: dict) -> str: + """Dump YAML to a string and remove null.""" + return yaml.safe_dump( + _dict, default_flow_style=False, allow_unicode=True) \ + .replace(': null\n', ':\n') + + +def save_yaml(path: str, data: dict) -> None: + """Save YAML to a file.""" + # Dump before writing to not truncate the file if dumping fails + str_data = dump(data) + with open(path, 'w', encoding='utf-8') as outfile: + outfile.write(str_data) + + +# From: https://gist.github.com/miracle2k/3184458 +# pylint: disable=redefined-outer-name +def represent_odict(dump, tag, mapping, # type: ignore + flow_style=None) -> yaml.MappingNode: + """Like BaseRepresenter.represent_mapping but does not issue the sort().""" + value = [] # type: list + node = yaml.MappingNode(tag, value, flow_style=flow_style) + if dump.alias_key is not None: + dump.represented_objects[dump.alias_key] = node + best_style = True + if hasattr(mapping, 'items'): + mapping = mapping.items() + for item_key, item_value in mapping: + node_key = dump.represent_data(item_key) + node_value = dump.represent_data(item_value) + if not (isinstance(node_key, yaml.ScalarNode) and not node_key.style): + best_style = False + if not (isinstance(node_value, yaml.ScalarNode) and + not node_value.style): + best_style = False + value.append((node_key, node_value)) + if flow_style is None: + if dump.default_flow_style is not None: + node.flow_style = dump.default_flow_style + else: + node.flow_style = best_style + return node + + +yaml.SafeDumper.add_representer( + OrderedDict, + lambda dumper, value: + represent_odict(dumper, 'tag:yaml.org,2002:map', value)) + +yaml.SafeDumper.add_representer( + NodeListClass, + lambda dumper, value: + dumper.represent_sequence('tag:yaml.org,2002:seq', value)) diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml/loader.py similarity index 83% rename from homeassistant/util/yaml.py rename to homeassistant/util/yaml/loader.py index f6d967b6e5a..7d228490c4c 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml/loader.py @@ -1,4 +1,4 @@ -"""YAML utility functions.""" +"""Custom loader.""" import logging import os import sys @@ -7,6 +7,7 @@ from collections import OrderedDict from typing import Union, List, Dict, Iterator, overload, TypeVar import yaml + try: import keyring except ImportError: @@ -19,25 +20,23 @@ except ImportError: from homeassistant.exceptions import HomeAssistantError +from .const import _SECRET_NAMESPACE, SECRET_YAML +from .objects import NodeListClass, NodeStrClass + + _LOGGER = logging.getLogger(__name__) -_SECRET_NAMESPACE = 'homeassistant' -SECRET_YAML = 'secrets.yaml' __SECRET_CACHE = {} # type: Dict[str, JSON_TYPE] JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name DICT_T = TypeVar('DICT_T', bound=Dict) # pylint: disable=invalid-name -class NodeListClass(list): - """Wrapper class to be able to add attributes on a list.""" +def clear_secret_cache() -> None: + """Clear the secret cache. - pass - - -class NodeStrClass(str): - """Wrapper class to be able to add attributes on a string.""" - - pass + Async friendly. + """ + __SECRET_CACHE.clear() # pylint: disable=too-many-ancestors @@ -54,6 +53,21 @@ class SafeLineLoader(yaml.SafeLoader): return node +def load_yaml(fname: str) -> JSON_TYPE: + """Load a YAML file.""" + try: + with open(fname, encoding='utf-8') as conf_file: + # If configuration file is empty YAML returns None + # We convert that to an empty dict + return yaml.load(conf_file, Loader=SafeLineLoader) or OrderedDict() + except yaml.YAMLError as exc: + _LOGGER.error(str(exc)) + raise HomeAssistantError(exc) + except UnicodeDecodeError as exc: + _LOGGER.error("Unable to read file %s: %s", fname, exc) + raise HomeAssistantError(exc) + + # pylint: disable=pointless-statement @overload def _add_reference(obj: Union[list, NodeListClass], @@ -86,44 +100,6 @@ def _add_reference(obj, loader: SafeLineLoader, # type: ignore # noqa: F811 return obj -def load_yaml(fname: str) -> JSON_TYPE: - """Load a YAML file.""" - try: - with open(fname, encoding='utf-8') as conf_file: - # If configuration file is empty YAML returns None - # We convert that to an empty dict - return yaml.load(conf_file, Loader=SafeLineLoader) or OrderedDict() - except yaml.YAMLError as exc: - _LOGGER.error(str(exc)) - raise HomeAssistantError(exc) - except UnicodeDecodeError as exc: - _LOGGER.error("Unable to read file %s: %s", fname, exc) - raise HomeAssistantError(exc) - - -def dump(_dict: dict) -> str: - """Dump YAML to a string and remove null.""" - return yaml.safe_dump( - _dict, default_flow_style=False, allow_unicode=True) \ - .replace(': null\n', ':\n') - - -def save_yaml(path: str, data: dict) -> None: - """Save YAML to a file.""" - # Dump before writing to not truncate the file if dumping fails - str_data = dump(data) - with open(path, 'w', encoding='utf-8') as outfile: - outfile.write(str_data) - - -def clear_secret_cache() -> None: - """Clear the secret cache. - - Async friendly. - """ - __SECRET_CACHE.clear() - - def _include_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> JSON_TYPE: """Load another YAML file and embeds it using the !include tag. @@ -331,43 +307,3 @@ yaml.SafeLoader.add_constructor('!include_dir_merge_list', yaml.SafeLoader.add_constructor('!include_dir_named', _include_dir_named_yaml) yaml.SafeLoader.add_constructor('!include_dir_merge_named', _include_dir_merge_named_yaml) - - -# From: https://gist.github.com/miracle2k/3184458 -# pylint: disable=redefined-outer-name -def represent_odict(dump, tag, mapping, # type: ignore - flow_style=None) -> yaml.MappingNode: - """Like BaseRepresenter.represent_mapping but does not issue the sort().""" - value = [] # type: list - node = yaml.MappingNode(tag, value, flow_style=flow_style) - if dump.alias_key is not None: - dump.represented_objects[dump.alias_key] = node - best_style = True - if hasattr(mapping, 'items'): - mapping = mapping.items() - for item_key, item_value in mapping: - node_key = dump.represent_data(item_key) - node_value = dump.represent_data(item_value) - if not (isinstance(node_key, yaml.ScalarNode) and not node_key.style): - best_style = False - if not (isinstance(node_value, yaml.ScalarNode) and - not node_value.style): - best_style = False - value.append((node_key, node_value)) - if flow_style is None: - if dump.default_flow_style is not None: - node.flow_style = dump.default_flow_style - else: - node.flow_style = best_style - return node - - -yaml.SafeDumper.add_representer( - OrderedDict, - lambda dumper, value: - represent_odict(dumper, 'tag:yaml.org,2002:map', value)) - -yaml.SafeDumper.add_representer( - NodeListClass, - lambda dumper, value: - dumper.represent_sequence('tag:yaml.org,2002:seq', value)) diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py new file mode 100644 index 00000000000..183c6c171d6 --- /dev/null +++ b/homeassistant/util/yaml/objects.py @@ -0,0 +1,13 @@ +"""Custom yaml object types.""" + + +class NodeListClass(list): + """Wrapper class to be able to add attributes on a list.""" + + pass + + +class NodeStrClass(str): + """Wrapper class to be able to add attributes on a string.""" + + pass diff --git a/mypy.ini b/mypy.ini index ee893476eed..2599eb079e0 100644 --- a/mypy.ini +++ b/mypy.ini @@ -17,7 +17,11 @@ disallow_untyped_defs = true [mypy-homeassistant.config_entries] disallow_untyped_defs = false -[mypy-homeassistant.util.yaml] +[mypy-homeassistant.util.yaml.dumper] +warn_return_any = false +disallow_untyped_calls = false + +[mypy-homeassistant.util.yaml.loader] warn_return_any = false disallow_untyped_calls = false diff --git a/pylintrc b/pylintrc index 7d349033f70..1ba0bf2c82a 100644 --- a/pylintrc +++ b/pylintrc @@ -1,3 +1,9 @@ +[MASTER] +ignore=tests + +[BASIC] +good-names=i,j,k,ex,Run,_,fp + [MESSAGES CONTROL] # Reasons disabled: # locally-disabled - it spams too much diff --git a/requirements_all.txt b/requirements_all.txt index 10801833614..debfb43b41e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,6 +5,7 @@ async_timeout==3.0.1 attrs==19.1.0 bcrypt==3.1.6 certifi>=2018.04.16 +importlib-metadata==0.15 jinja2>=2.10 PyJWT==1.7.1 cryptography==2.6.1 @@ -12,7 +13,7 @@ pip>=8.0.3 python-slugify==3.0.2 pytz>=2019.01 pyyaml>=3.13,<4 -requests==2.21.0 +requests==2.22.0 ruamel.yaml==0.15.94 voluptuous==0.11.5 voluptuous-serialize==2.1.0 @@ -36,13 +37,13 @@ Adafruit-SHT31==1.0.2 HAP-python==2.5.0 # homeassistant.components.mastodon -Mastodon.py==1.4.0 +Mastodon.py==1.4.2 # homeassistant.components.orangepi_gpio OPi.GPIO==0.3.6 # homeassistant.components.essent -PyEssent==0.10 +PyEssent==0.12 # homeassistant.components.github PyGithub==1.43.5 @@ -75,6 +76,7 @@ PyTransportNSW==0.1.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.12.3 +# homeassistant.components.mcp23017 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.5 @@ -99,6 +101,12 @@ YesssSMS==0.2.3 # homeassistant.components.abode abodepy==0.15.0 +# homeassistant.components.mcp23017 +adafruit-blinka==1.2.1 + +# homeassistant.components.mcp23017 +adafruit-circuitpython-mcp230xx==1.1.2 + # homeassistant.components.frontier_silicon afsapi==0.0.4 @@ -118,7 +126,7 @@ aiobotocore==0.10.2 aiodns==2.0.0 # homeassistant.components.esphome -aioesphomeapi==2.0.1 +aioesphomeapi==2.1.0 # homeassistant.components.freebox aiofreepybox==0.0.8 @@ -164,7 +172,7 @@ alarmdecoder==1.13.2 alpha_vantage==2.1.0 # homeassistant.components.ambiclimate -ambiclimate==0.1.1 +ambiclimate==0.1.2 # homeassistant.components.amcrest amcrest==1.4.0 @@ -204,7 +212,10 @@ av==6.1.2 # avion==0.10 # homeassistant.components.axis -axis==22 +axis==24 + +# homeassistant.components.azure_event_hub +azure-eventhub==1.3.1 # homeassistant.components.baidu baidu-aip==1.6.6 @@ -233,7 +244,7 @@ bimmer_connected==0.5.3 bizkaibus==0.1.1 # homeassistant.components.blink -blinkpy==0.13.1 +blinkpy==0.14.0 # homeassistant.components.blinksticklight blinkstick==1.1.8 @@ -401,7 +412,7 @@ enturclient==0.2.0 # envirophat==0.0.6 # homeassistant.components.enphase_envoy -envoy_reader==0.3 +envoy_reader==0.4 # homeassistant.components.season ephem==3.7.6.0 @@ -442,7 +453,7 @@ fiblary3==0.1.7 fints==1.0.1 # homeassistant.components.fitbit -fitbit==0.3.0 +fitbit==0.3.1 # homeassistant.components.fixer fixerio==1.0.0a0 @@ -474,7 +485,7 @@ gearbest_parser==1.0.7 geizhals==0.0.9 # homeassistant.components.geniushub -geniushub-client==0.4.6 +geniushub-client==0.4.11 # homeassistant.components.geo_json_events # homeassistant.components.nsw_rural_fire_service_feed @@ -508,6 +519,9 @@ googledevices==1.0.2 # homeassistant.components.google_travel_time googlemaps==2.5.1 +# homeassistant.components.remote_rpi_gpio +gpiozero==1.4.1 + # homeassistant.components.gpsd gps3==0.33.3 @@ -533,7 +547,7 @@ habitipy==0.2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.12 +hass-nabucasa==0.13 # homeassistant.components.mqtt hbmqtt==0.9.4 @@ -563,7 +577,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190514.0 +home-assistant-frontend==20190604.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 @@ -582,7 +596,7 @@ horimote==0.4.1 httplib2==0.10.3 # homeassistant.components.huawei_lte -huawei-lte-api==1.1.5 +huawei-lte-api==1.2.0 # homeassistant.components.hydrawise hydrawiser==0.1.1 @@ -592,6 +606,9 @@ hydrawiser==0.1.1 # homeassistant.components.htu21d # i2csense==0.0.4 +# homeassistant.components.watson_tts +ibm-watson==3.0.3 + # homeassistant.components.watson_iot ibmiotf==0.3.4 @@ -602,13 +619,13 @@ iglo==1.2.7 ihcsdk==2.3.0 # homeassistant.components.incomfort -incomfort-client==0.2.8 +incomfort-client==0.2.9 # homeassistant.components.influxdb influxdb==5.2.0 # homeassistant.components.insteon -insteonplm==0.15.2 +insteonplm==0.15.4 # homeassistant.components.iperf3 iperf3==0.1.10 @@ -749,7 +766,7 @@ n26==0.2.7 nad_receiver==0.0.11 # homeassistant.components.keenetic_ndms2 -ndms2_client==0.0.6 +ndms2_client==0.0.7 # homeassistant.components.ness_alarm nessclient==0.9.15 @@ -758,6 +775,7 @@ nessclient==0.9.15 netdata==0.1.2 # homeassistant.components.discovery +# homeassistant.components.ssdp netdisco==2.6.0 # homeassistant.components.neurio_energy @@ -900,7 +918,7 @@ psutil==5.6.2 ptvsd==4.2.8 # homeassistant.components.wink -pubnubsub-handler==1.0.4 +pubnubsub-handler==1.0.6 # homeassistant.components.pushbullet pushbullet.py==0.11.0 @@ -976,7 +994,7 @@ pyalarmdotcom==0.3.2 pyarlo==0.2.3 # homeassistant.components.netatmo -pyatmo==1.11 +pyatmo==1.12 # homeassistant.components.apple_tv pyatv==0.3.12 @@ -1021,13 +1039,13 @@ pycsspeechtts==1.0.2 # pycups==1.9.73 # homeassistant.components.daikin -pydaikin==1.4.0 +pydaikin==1.4.6 # homeassistant.components.danfoss_air pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==58 +pydeconz==59 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -1127,7 +1145,7 @@ pyicloud==0.9.1 pyipma==1.2.1 # homeassistant.components.iqvia -pyiqvia==0.2.0 +pyiqvia==0.2.1 # homeassistant.components.irish_rail_transport pyirishrail==0.0.2 @@ -1166,7 +1184,7 @@ pylinky==0.3.3 pylitejet==0.1 # homeassistant.components.loopenergy -pyloopenergy==0.1.2 +pyloopenergy==0.1.3 # homeassistant.components.lutron_caseta pylutron-caseta==0.5.0 @@ -1208,7 +1226,7 @@ pynanoleaf==0.0.5 pynello==2.0.2 # homeassistant.components.netgear -pynetgear==0.5.2 +pynetgear==0.6.1 # homeassistant.components.netio pynetio==0.1.9.1 @@ -1232,7 +1250,7 @@ pyoppleio==1.0.5 pyota==2.0.5 # homeassistant.components.opentherm_gw -pyotgw==0.4b3 +pyotgw==0.4b4 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp @@ -1246,7 +1264,7 @@ pyowlet==1.0.2 pyowm==2.10.0 # homeassistant.components.lcn -pypck==0.5.9 +pypck==0.6.0 # homeassistant.components.pjlink pypjlink2==1.2.0 @@ -1269,6 +1287,9 @@ pyrainbird==0.1.6 # homeassistant.components.recswitch pyrecswitch==1.0.2 +# homeassistant.components.repetier +pyrepetier==3.0.5 + # homeassistant.components.ruter pyruter==1.1.0 @@ -1561,7 +1582,7 @@ rova==0.1.0 russound==0.1.9 # homeassistant.components.russound_rio -russound_rio==0.1.4 +russound_rio==0.1.7 # homeassistant.components.yamaha rxv==0.6.0 @@ -1617,6 +1638,9 @@ slixmpp==1.4.2 # homeassistant.components.smappee smappy==0.2.16 +# homeassistant.components.smarthab +smarthab==0.20 + # homeassistant.components.bh1750 # homeassistant.components.bme280 # homeassistant.components.bme680 @@ -1637,6 +1661,9 @@ socialbladeclient==0.2 # homeassistant.components.solaredge solaredge==0.0.2 +# homeassistant.components.solax +solax==0.0.3 + # homeassistant.components.honeywell somecomfort==0.5.2 @@ -1683,7 +1710,7 @@ sucks==0.9.4 swisshydrodata==0.0.3 # homeassistant.components.synology_srm -synology-srm==0.0.6 +synology-srm==0.0.7 # homeassistant.components.tahoma tahoma-api==0.0.14 @@ -1761,7 +1788,7 @@ uscisstatus==0.1.1 uvcclient==0.11.0 # homeassistant.components.venstar -venstarcolortouch==0.6 +venstarcolortouch==0.7 # homeassistant.components.volkszaehler volkszaehler==0.1.2 @@ -1842,13 +1869,13 @@ yeelight==0.5.0 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2019.04.30 +youtube_dl==2019.05.11 # homeassistant.components.zengge zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.22.0 +zeroconf==0.23.0 # homeassistant.components.zha zha-quirks==0.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a91d382fe80..cd76ca3f748 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -44,6 +44,9 @@ aioautomatic==0.6.5 # homeassistant.components.aws aiobotocore==0.10.2 +# homeassistant.components.esphome +aioesphomeapi==2.1.0 + # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 @@ -58,7 +61,7 @@ aioswitcher==2019.3.21 aiounifi==4 # homeassistant.components.ambiclimate -ambiclimate==0.1.1 +ambiclimate==0.1.2 # homeassistant.components.apns apns2==0.3.0 @@ -67,7 +70,7 @@ apns2==0.3.0 av==6.1.2 # homeassistant.components.axis -axis==22 +axis==24 # homeassistant.components.zha bellows-homeassistant==0.7.3 @@ -133,7 +136,7 @@ ha-ffmpeg==2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.12 +hass-nabucasa==0.13 # homeassistant.components.mqtt hbmqtt==0.9.4 @@ -145,7 +148,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190514.0 +home-assistant-frontend==20190604.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 @@ -178,6 +181,10 @@ mbddns==0.1.2 # homeassistant.components.mfi mficlient==0.3.0 +# homeassistant.components.discovery +# homeassistant.components.ssdp +netdisco==2.6.0 + # homeassistant.components.iqvia # homeassistant.components.opencv # homeassistant.components.tensorflow @@ -223,7 +230,7 @@ pyHS100==0.3.5 pyblackbird==0.5 # homeassistant.components.deconz -pydeconz==58 +pydeconz==59 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -234,6 +241,9 @@ pyheos==0.5.2 # homeassistant.components.homematic pyhomematic==0.1.58 +# homeassistant.components.iqvia +pyiqvia==0.2.1 + # homeassistant.components.litejet pylitejet==0.1 @@ -341,5 +351,8 @@ vultr==0.1.2 # homeassistant.components.wake_on_lan wakeonlan==1.1.6 +# homeassistant.components.zeroconf +zeroconf==0.23.0 + # homeassistant.components.zha zigpy-homeassistant==0.3.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 14303bd6d65..f85758e464f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -46,6 +46,7 @@ TEST_REQUIREMENTS = ( 'aioambient', 'aioautomatic', 'aiobotocore', + 'aioesphomeapi', 'aiohttp_cors', 'aiohue', 'aiounifi', @@ -88,6 +89,7 @@ TEST_REQUIREMENTS = ( 'luftdaten', 'mbddns', 'mficlient', + 'netdisco', 'numpy', 'oauth2client', 'paho-mqtt', @@ -103,6 +105,7 @@ TEST_REQUIREMENTS = ( 'pydispatcher', 'pyheos', 'pyhomematic', + 'pyiqvia', 'pylitejet', 'pymonoprice', 'pynx584', @@ -146,6 +149,7 @@ TEST_REQUIREMENTS = ( 'vultr', 'YesssSMS', 'ruamel.yaml', + 'zeroconf', 'zigpy-homeassistant', 'bellows-homeassistant', ) diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index bca419126db..5ee52e72f7a 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -3,13 +3,24 @@ import pathlib import sys from .model import Integration, Config -from . import dependencies, manifest, codeowners, services +from . import ( + codeowners, + config_flow, + dependencies, + manifest, + services, + ssdp, + zeroconf, +) PLUGINS = [ - manifest, - dependencies, codeowners, + config_flow, + dependencies, + manifest, services, + ssdp, + zeroconf, ] diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py new file mode 100644 index 00000000000..2f204227f25 --- /dev/null +++ b/script/hassfest/config_flow.py @@ -0,0 +1,85 @@ +"""Generate config flow file.""" +import json +from typing import Dict + +from .model import Integration, Config + +BASE = """ +\"\"\"Automatically generated by hassfest. + +To update, run python3 -m hassfest +\"\"\" + + +FLOWS = {} +""".strip() + + +def validate_integration(integration: Integration): + """Validate we can load config flow without installing requirements.""" + if not (integration.path / "config_flow.py").is_file(): + integration.add_error( + 'config_flow', + "Config flows need to be defined in the file config_flow.py") + + # Currently not require being able to load config flow without + # installing requirements. + # try: + # integration.import_pkg('config_flow') + # except ImportError as err: + # integration.add_error( + # 'config_flow', + # "Unable to import config flow: {}. Config flows should be able " + # "to be imported without installing requirements.".format(err)) + # return + + # if integration.domain not in config_entries.HANDLERS: + # integration.add_error( + # 'config_flow', + # "Importing the config flow platform did not register a config " + # "flow handler.") + + +def generate_and_validate(integrations: Dict[str, Integration]): + """Validate and generate config flow data.""" + domains = [] + + for domain in sorted(integrations): + integration = integrations[domain] + + if not integration.manifest: + continue + + config_flow = integration.manifest.get('config_flow') + + if not config_flow: + continue + + validate_integration(integration) + + domains.append(domain) + + return BASE.format(json.dumps(domains, indent=4)) + + +def validate(integrations: Dict[str, Integration], config: Config): + """Validate config flow file.""" + config_flow_path = config.root / 'homeassistant/generated/config_flows.py' + config.cache['config_flow'] = content = generate_and_validate(integrations) + + with open(str(config_flow_path), 'r') as fp: + if fp.read().strip() != content: + config.add_error( + "config_flow", + "File config_flows.py is not up to date. " + "Run python3 -m script.hassfest", + fixable=True + ) + return + + +def generate(integrations: Dict[str, Integration], config: Config): + """Generate config flow file.""" + config_flow_path = config.root / 'homeassistant/generated/config_flows.py' + with open(str(config_flow_path), 'w') as fp: + fp.write(config.cache['config_flow'] + '\n') diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 30f89231299..3e25ab31712 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -10,6 +10,16 @@ from .model import Integration MANIFEST_SCHEMA = vol.Schema({ vol.Required('domain'): str, vol.Required('name'): str, + vol.Optional('config_flow'): bool, + vol.Optional('zeroconf'): [str], + vol.Optional('ssdp'): vol.Schema({ + vol.Optional('st'): [str], + vol.Optional('manufacturer'): [str], + vol.Optional('device_type'): [str], + }), + vol.Optional('homekit'): vol.Schema({ + vol.Optional('models'): [str], + }), vol.Required('documentation'): str, vol.Required('requirements'): [str], vol.Required('dependencies'): [str], diff --git a/script/hassfest/model.py b/script/hassfest/model.py index de252715992..4815522cf94 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -2,6 +2,7 @@ import json from typing import List, Dict, Any import pathlib +import importlib import attr @@ -92,3 +93,10 @@ class Integration: return self.manifest = manifest + + def import_pkg(self, platform=None): + """Import the Python file.""" + pkg = "homeassistant.components.{}".format(self.domain) + if platform is not None: + pkg += ".{}".format(platform) + return importlib.import_module(pkg) diff --git a/script/hassfest/ssdp.py b/script/hassfest/ssdp.py new file mode 100644 index 00000000000..b5c4b9721c0 --- /dev/null +++ b/script/hassfest/ssdp.py @@ -0,0 +1,88 @@ +"""Generate ssdp file.""" +from collections import OrderedDict, defaultdict +import json +from typing import Dict + +from .model import Integration, Config + +BASE = """ +\"\"\"Automatically generated by hassfest. + +To update, run python3 -m hassfest +\"\"\" + + +SSDP = {} +""".strip() + + +def sort_dict(value): + """Sort a dictionary.""" + return OrderedDict((key, value[key]) + for key in sorted(value)) + + +def generate_and_validate(integrations: Dict[str, Integration]): + """Validate and generate ssdp data.""" + data = { + 'st': defaultdict(list), + 'manufacturer': defaultdict(list), + 'device_type': defaultdict(list), + } + + for domain in sorted(integrations): + integration = integrations[domain] + + if not integration.manifest: + continue + + ssdp = integration.manifest.get('ssdp') + + if not ssdp: + continue + + try: + with open(str(integration.path / "config_flow.py")) as fp: + if ' async_step_ssdp(' not in fp.read(): + integration.add_error( + 'ssdp', 'Config flow has no async_step_ssdp') + continue + except FileNotFoundError: + integration.add_error( + 'ssdp', + 'SSDP info in a manifest requires a config flow to exist' + ) + continue + + for key in 'st', 'manufacturer', 'device_type': + if key not in ssdp: + continue + + for value in ssdp[key]: + data[key][value].append(domain) + + data = sort_dict({key: sort_dict(value) for key, value in data.items()}) + return BASE.format(json.dumps(data, indent=4)) + + +def validate(integrations: Dict[str, Integration], config: Config): + """Validate ssdp file.""" + ssdp_path = config.root / 'homeassistant/generated/ssdp.py' + config.cache['ssdp'] = content = generate_and_validate(integrations) + + with open(str(ssdp_path), 'r') as fp: + if fp.read().strip() != content: + config.add_error( + "ssdp", + "File ssdp.py is not up to date. " + "Run python3 -m script.hassfest", + fixable=True + ) + return + + +def generate(integrations: Dict[str, Integration], config: Config): + """Generate ssdp file.""" + ssdp_path = config.root / 'homeassistant/generated/ssdp.py' + with open(str(ssdp_path), 'w') as fp: + fp.write(config.cache['ssdp'] + '\n') diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py new file mode 100644 index 00000000000..25e8da99b55 --- /dev/null +++ b/script/hassfest/zeroconf.py @@ -0,0 +1,131 @@ +"""Generate zeroconf file.""" +from collections import OrderedDict, defaultdict +import json +from typing import Dict + +from .model import Integration, Config + +BASE = """ +\"\"\"Automatically generated by hassfest. + +To update, run python3 -m hassfest +\"\"\" + + +ZEROCONF = {} + +HOMEKIT = {} +""".strip() + + +def generate_and_validate(integrations: Dict[str, Integration]): + """Validate and generate zeroconf data.""" + service_type_dict = defaultdict(list) + homekit_dict = {} + + for domain in sorted(integrations): + integration = integrations[domain] + + if not integration.manifest: + continue + + service_types = integration.manifest.get('zeroconf', []) + homekit = integration.manifest.get('homekit', {}) + homekit_models = homekit.get('models', []) + + if not service_types and not homekit_models: + continue + + try: + with open(str(integration.path / "config_flow.py")) as fp: + content = fp.read() + uses_discovery_flow = 'register_discovery_flow' in content + + if (service_types and not uses_discovery_flow and + ' async_step_zeroconf(' not in content): + integration.add_error( + 'zeroconf', 'Config flow has no async_step_zeroconf') + continue + + if (homekit_models and not uses_discovery_flow and + ' async_step_homekit(' not in content): + integration.add_error( + 'zeroconf', 'Config flow has no async_step_homekit') + continue + + except FileNotFoundError: + integration.add_error( + 'zeroconf', + 'Zeroconf info in a manifest requires a config flow to exist' + ) + continue + + for service_type in service_types: + service_type_dict[service_type].append(domain) + + for model in homekit_models: + # We add a space, as we want to test for it to be model + space. + model += " " + + if model in homekit_dict: + integration.add_error( + 'zeroconf', + 'Integrations {} and {} have overlapping HomeKit ' + 'models'.format(domain, homekit_dict[model])) + break + + homekit_dict[model] = domain + + # HomeKit models are matched on starting string, make sure none overlap. + warned = set() + for key in homekit_dict: + if key in warned: + continue + + # n^2 yoooo + for key_2 in homekit_dict: + if key == key_2 or key_2 in warned: + continue + + if key.startswith(key_2) or key_2.startswith(key): + integration.add_error( + 'zeroconf', + 'Integrations {} and {} have overlapping HomeKit ' + 'models'.format(homekit_dict[key], homekit_dict[key_2])) + warned.add(key) + warned.add(key_2) + break + + zeroconf = OrderedDict((key, service_type_dict[key]) + for key in sorted(service_type_dict)) + homekit = OrderedDict((key, homekit_dict[key]) + for key in sorted(homekit_dict)) + + return BASE.format( + json.dumps(zeroconf, indent=4), + json.dumps(homekit, indent=4), + ) + + +def validate(integrations: Dict[str, Integration], config: Config): + """Validate zeroconf file.""" + zeroconf_path = config.root / 'homeassistant/generated/zeroconf.py' + config.cache['zeroconf'] = content = generate_and_validate(integrations) + + with open(str(zeroconf_path), 'r') as fp: + current = fp.read().strip() + if current != content: + config.add_error( + "zeroconf", + "File zeroconf.py is not up to date. " + "Run python3 -m script.hassfest", + fixable=True + ) + return + + +def generate(integrations: Dict[str, Integration], config: Config): + """Generate zeroconf file.""" + zeroconf_path = config.root / 'homeassistant/generated/zeroconf.py' + with open(str(zeroconf_path), 'w') as fp: + fp.write(config.cache['zeroconf'] + '\n') diff --git a/setup.py b/setup.py index 4de6fa2f042..2ae5d8e8c3b 100755 --- a/setup.py +++ b/setup.py @@ -38,6 +38,7 @@ REQUIRES = [ 'attrs==19.1.0', 'bcrypt==3.1.6', 'certifi>=2018.04.16', + 'importlib-metadata==0.15', 'jinja2>=2.10', 'PyJWT==1.7.1', # PyJWT has loose dependency. We want the latest one. @@ -46,7 +47,7 @@ REQUIRES = [ 'python-slugify==3.0.2', 'pytz>=2019.01', 'pyyaml>=3.13,<4', - 'requests==2.21.0', + 'requests==2.22.0', 'ruamel.yaml==0.15.94', 'voluptuous==0.11.5', 'voluptuous-serialize==2.1.0', diff --git a/tests/common.py b/tests/common.py index 46e30187d45..f934d2990d3 100644 --- a/tests/common.py +++ b/tests/common.py @@ -15,7 +15,8 @@ from io import StringIO from unittest.mock import MagicMock, Mock, patch import homeassistant.util.dt as date_util -import homeassistant.util.yaml as yaml +import homeassistant.util.yaml.loader as yaml_loader +import homeassistant.util.yaml.dumper as yaml_dumper from homeassistant import auth, config_entries, core as ha, loader from homeassistant.auth import ( @@ -86,6 +87,7 @@ def get_test_home_assistant(): else: loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) hass = loop.run_until_complete(async_test_home_assistant(loop)) stop_event = threading.Event() @@ -101,7 +103,7 @@ def get_test_home_assistant(): def start_hass(*mocks): """Start hass.""" - run_coroutine_threadsafe(hass.async_start(), loop=hass.loop).result() + run_coroutine_threadsafe(hass.async_start(), loop).result() def stop_hass(): """Stop hass.""" @@ -121,7 +123,6 @@ def get_test_home_assistant(): async def async_test_home_assistant(loop): """Return a Home Assistant object pointing at test config dir.""" hass = ha.HomeAssistant(loop) - hass.config.async_load = Mock() store = auth_store.AuthStore(hass) hass.auth = auth.AuthManager(hass, store, {}, {}) ensure_auth_manager_loaded(hass.auth) @@ -680,7 +681,8 @@ def patch_yaml_files(files_dict, endswith=True): # Not found raise FileNotFoundError("File not found: {}".format(fname)) - return patch.object(yaml, 'open', mock_open_f, create=True) + return patch.object(yaml_loader, 'open', mock_open_f, create=True) + return patch.object(yaml_dumper, 'open', mock_open_f, create=True) def mock_coro(return_value=None, exception=None): @@ -924,7 +926,7 @@ async def get_system_health_info(hass, domain): def mock_integration(hass, module): """Mock an integration.""" integration = loader.Integration( - hass, 'homeassisant.components.{}'.format(module.DOMAIN), None, + hass, 'homeassistant.components.{}'.format(module.DOMAIN), None, module.mock_manifest()) _LOGGER.info("Adding mock integration: %s", module.DOMAIN) @@ -949,3 +951,16 @@ def mock_entity_platform(hass, platform_path, module): _LOGGER.info("Adding mock integration platform: %s", platform_path) module_cache["{}.{}".format(platform_name, domain)] = module + + +def async_capture_events(hass, event_name): + """Create a helper that captures events.""" + events = [] + + @ha.callback + def capture_events(event): + events.append(event) + + hass.bus.async_listen(event_name, capture_events) + + return events diff --git a/tests/components/ambiclimate/test_config_flow.py b/tests/components/ambiclimate/test_config_flow.py index f61665b9e5b..64c3644aaa2 100644 --- a/tests/components/ambiclimate/test_config_flow.py +++ b/tests/components/ambiclimate/test_config_flow.py @@ -86,12 +86,14 @@ async def test_full_flow_implementation(hass): assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT -async def test_abort_no_code(hass): +async def test_abort_invalid_code(hass): """Test if no code is given to step_code.""" config_flow.register_flow_implementation(hass, None, None) flow = await init_config_flow(hass) - result = await flow.async_step_code('invalid') + with patch('ambiclimate.AmbiclimateOAuth.get_access_token', + return_value=mock_coro(None)): + result = await flow.async_step_code('invalid') assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT assert result['reason'] == 'access_token' diff --git a/tests/components/automatic/test_device_tracker.py b/tests/components/automatic/test_device_tracker.py index 03b631b4689..317198f59c7 100644 --- a/tests/components/automatic/test_device_tracker.py +++ b/tests/components/automatic/test_device_tracker.py @@ -99,7 +99,7 @@ def test_valid_credentials( @asyncio.coroutine def ws_connect(): - return asyncio.Future(loop=hass.loop) + return asyncio.Future() mock_ws_connect.side_effect = ws_connect diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index a019f65afcf..179c5f84895 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -893,4 +893,4 @@ async def test_automation_with_error_in_script(hass, caplog): hass.bus.async_fire('test_event') await hass.async_block_till_done() - assert 'Service test.automation not found' in caplog.text + assert 'Service not found' in caplog.text diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index 1a83e9be8b5..ebd2062ee0f 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -161,8 +161,8 @@ async def test_flow_create_entry_more_entries(hass): assert result['data'][config_flow.CONF_NAME] == 'model 2' -async def test_discovery_flow(hass): - """Test that discovery for new devices work.""" +async def test_zeroconf_flow(hass): + """Test that zeroconf discovery for new devices work.""" with patch.object(axis, 'get_device', return_value=mock_coro(Mock())): result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, @@ -171,15 +171,15 @@ async def test_discovery_flow(hass): config_flow.CONF_PORT: 80, 'properties': {'macaddress': '1234'} }, - context={'source': 'discovery'} + context={'source': 'zeroconf'} ) assert result['type'] == 'form' assert result['step_id'] == 'user' -async def test_discovery_flow_known_device(hass): - """Test that discovery for known devices work. +async def test_zeroconf_flow_known_device(hass): + """Test that zeroconf discovery for known devices work. This is legacy support from devices registered with configurator. """ @@ -210,14 +210,14 @@ async def test_discovery_flow_known_device(hass): 'hostname': 'name', 'properties': {'macaddress': '1234ABCD'} }, - context={'source': 'discovery'} + context={'source': 'zeroconf'} ) assert result['type'] == 'create_entry' -async def test_discovery_flow_already_configured(hass): - """Test that discovery doesn't setup already configured devices.""" +async def test_zeroconf_flow_already_configured(hass): + """Test that zeroconf doesn't setup already configured devices.""" entry = MockConfigEntry( domain=axis.DOMAIN, data={axis.CONF_DEVICE: {axis.config_flow.CONF_HOST: '1.2.3.4'}, @@ -235,27 +235,27 @@ async def test_discovery_flow_already_configured(hass): 'hostname': 'name', 'properties': {'macaddress': '1234ABCD'} }, - context={'source': 'discovery'} + context={'source': 'zeroconf'} ) assert result['type'] == 'abort' assert result['reason'] == 'already_configured' -async def test_discovery_flow_ignore_link_local_address(hass): - """Test that discovery doesn't setup devices with link local addresses.""" +async def test_zeroconf_flow_ignore_link_local_address(hass): + """Test that zeroconf doesn't setup devices with link local addresses.""" result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, data={config_flow.CONF_HOST: '169.254.3.4'}, - context={'source': 'discovery'} + context={'source': 'zeroconf'} ) assert result['type'] == 'abort' assert result['reason'] == 'link_local_address' -async def test_discovery_flow_bad_config_file(hass): - """Test that discovery with bad config files abort.""" +async def test_zeroconf_flow_bad_config_file(hass): + """Test that zeroconf discovery with bad config files abort.""" with patch('homeassistant.components.axis.config_flow.load_json', return_value={'1234ABCD': { config_flow.CONF_HOST: '2.3.4.5', @@ -270,7 +270,7 @@ async def test_discovery_flow_bad_config_file(hass): config_flow.CONF_HOST: '1.2.3.4', 'properties': {'macaddress': '1234ABCD'} }, - context={'source': 'discovery'} + context={'source': 'zeroconf'} ) assert result['type'] == 'abort' diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py index 23714e51c88..ac2da3ddedc 100644 --- a/tests/components/axis/test_device.py +++ b/tests/components/axis/test_device.py @@ -37,6 +37,7 @@ async def test_device_setup(): api = Mock() axis_device = device.AxisNetworkDevice(hass, entry) + axis_device.start = Mock() assert axis_device.host == DEVICE_DATA[device.CONF_HOST] assert axis_device.model == ENTRY_CONFIG[device.CONF_MODEL] @@ -47,11 +48,13 @@ async def test_device_setup(): assert await axis_device.async_setup() is True assert axis_device.api is api - assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 2 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 3 assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ (entry, 'camera') assert hass.config_entries.async_forward_entry_setup.mock_calls[1][1] == \ (entry, 'binary_sensor') + assert hass.config_entries.async_forward_entry_setup.mock_calls[2][1] == \ + (entry, 'switch') async def test_device_signal_new_address(hass): @@ -71,7 +74,7 @@ async def test_device_signal_new_address(hass): await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 - assert len(axis_device.listeners) == 1 + assert len(axis_device.listeners) == 2 entry.data[device.CONF_DEVICE][device.CONF_HOST] = '2.3.4.5' hass.config_entries.async_update_entry(entry, data=entry.data) @@ -193,6 +196,8 @@ async def test_get_device(hass): with patch('axis.param_cgi.Params.update_brand', return_value=mock_coro()), \ patch('axis.param_cgi.Params.update_properties', + return_value=mock_coro()), \ + patch('axis.port_cgi.Ports.update', return_value=mock_coro()): assert await device.get_device(hass, DEVICE_DATA) diff --git a/tests/components/axis/test_switch.py b/tests/components/axis/test_switch.py new file mode 100644 index 00000000000..1acb81ee0a2 --- /dev/null +++ b/tests/components/axis/test_switch.py @@ -0,0 +1,120 @@ +"""Axis switch platform tests.""" + +from unittest.mock import call as mock_call, Mock + +from homeassistant import config_entries +from homeassistant.components import axis +from homeassistant.setup import async_setup_component + +import homeassistant.components.switch as switch + +EVENTS = [ + { + 'operation': 'Initialized', + 'topic': 'tns1:Device/Trigger/Relay', + 'source': 'RelayToken', + 'source_idx': '0', + 'type': 'LogicalState', + 'value': 'inactive' + }, + { + 'operation': 'Initialized', + 'topic': 'tns1:Device/Trigger/Relay', + 'source': 'RelayToken', + 'source_idx': '1', + 'type': 'LogicalState', + 'value': 'active' + } +] + +ENTRY_CONFIG = { + axis.CONF_DEVICE: { + axis.config_flow.CONF_HOST: '1.2.3.4', + axis.config_flow.CONF_USERNAME: 'user', + axis.config_flow.CONF_PASSWORD: 'pass', + axis.config_flow.CONF_PORT: 80 + }, + axis.config_flow.CONF_MAC: '1234ABCD', + axis.config_flow.CONF_MODEL: 'model', + axis.config_flow.CONF_NAME: 'model 0' +} + +ENTRY_OPTIONS = { + axis.CONF_CAMERA: False, + axis.CONF_EVENTS: True, + axis.CONF_TRIGGER_TIME: 0 +} + + +async def setup_device(hass): + """Load the Axis switch platform.""" + from axis import AxisDevice + loop = Mock() + + config_entry = config_entries.ConfigEntry( + 1, axis.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test', + config_entries.CONN_CLASS_LOCAL_PUSH, options=ENTRY_OPTIONS) + device = axis.AxisNetworkDevice(hass, config_entry) + device.api = AxisDevice(loop=loop, **config_entry.data[axis.CONF_DEVICE]) + hass.data[axis.DOMAIN] = {device.serial: device} + device.api.enable_events(event_callback=device.async_event_callback) + + 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() + + return device + + +async def test_platform_manually_configured(hass): + """Test that nothing happens when platform is manually configured.""" + assert await async_setup_component(hass, switch.DOMAIN, { + 'switch': { + 'platform': axis.DOMAIN + } + }) + + assert axis.DOMAIN not in hass.data + + +async def test_no_switches(hass): + """Test that no output events in Axis results in no switch entities.""" + await setup_device(hass) + + assert not hass.states.async_entity_ids('switch') + + +async def test_switches(hass): + """Test that switches are loaded properly.""" + device = await setup_device(hass) + device.api.vapix.ports = {'0': Mock(), '1': Mock()} + device.api.vapix.ports['0'].name = 'Doorbell' + device.api.vapix.ports['1'].name = '' + + for event in EVENTS: + device.api.stream.event.manage_event(event) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 3 + + relay_0 = hass.states.get('switch.model_0_doorbell') + assert relay_0.state == 'off' + assert relay_0.name == 'model 0 Doorbell' + + relay_1 = hass.states.get('switch.model_0_relay_1') + assert relay_1.state == 'on' + assert relay_1.name == 'model 0 Relay 1' + + device.api.vapix.ports['0'].action = Mock() + + await hass.services.async_call('switch', 'turn_on', { + 'entity_id': 'switch.model_0_doorbell' + }, blocking=True) + + await hass.services.async_call('switch', 'turn_off', { + 'entity_id': 'switch.model_0_doorbell' + }, blocking=True) + + assert device.api.vapix.ports['0'].action.call_args_list == \ + [mock_call('/'), mock_call('\\')] diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index e12cca75c61..75ee8f6c665 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -209,8 +209,7 @@ async def test_websocket_camera_stream(hass, hass_ws_client, return_value='http://home.assistant/playlist.m3u8' ) as mock_request_stream, \ patch('homeassistant.components.demo.camera.DemoCamera.stream_source', - new_callable=PropertyMock) as mock_stream_source: - mock_stream_source.return_value = io.BytesIO() + return_value=mock_coro('http://example.com')): # Request playlist through WebSocket client = await hass_ws_client(hass) await client.send_json({ @@ -289,8 +288,7 @@ async def test_handle_play_stream_service(hass, mock_camera, mock_stream): with patch('homeassistant.components.camera.request_stream' ) as mock_request_stream, \ patch('homeassistant.components.demo.camera.DemoCamera.stream_source', - new_callable=PropertyMock) as mock_stream_source: - mock_stream_source.return_value = io.BytesIO() + return_value=mock_coro('http://example.com')): # Call service await hass.services.async_call( camera.DOMAIN, camera.SERVICE_PLAY_STREAM, data, blocking=True) @@ -331,8 +329,7 @@ async def test_preload_stream(hass, mock_stream): patch('homeassistant.components.camera.prefs.CameraPreferences.get', return_value=demo_prefs), \ patch('homeassistant.components.demo.camera.DemoCamera.stream_source', - new_callable=PropertyMock) as mock_stream_source: - mock_stream_source.return_value = io.BytesIO() + return_value=mock_coro("http://example.com")): await async_setup_component(hass, 'camera', { DOMAIN: { 'platform': 'demo' @@ -364,12 +361,11 @@ async def test_record_service(hass, mock_camera, mock_stream): } with patch('homeassistant.components.demo.camera.DemoCamera.stream_source', - new_callable=PropertyMock) as mock_stream_source, \ + return_value=mock_coro("http://example.com")), \ patch( 'homeassistant.components.stream.async_handle_record_service', return_value=mock_coro()) as mock_record_service, \ patch.object(hass.config, 'is_allowed_path', return_value=True): - mock_stream_source.return_value = io.BytesIO() # Call service await hass.services.async_call( camera.DOMAIN, camera.SERVICE_RECORD, data, blocking=True) diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 4440651d089..fa1d8cf8b9b 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -2,9 +2,12 @@ from unittest.mock import patch, MagicMock from aiohttp import web +import jwt import pytest +from homeassistant.core import State from homeassistant.setup import async_setup_component +from homeassistant.components.cloud import DOMAIN from homeassistant.components.cloud.const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) from tests.components.alexa import test_smart_home as test_alexa @@ -19,6 +22,25 @@ def mock_cloud(): return MagicMock(subscription_expired=False) +@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': {} + }) + + +@pytest.fixture +def mock_cloud_login(hass, mock_cloud_setup): + """Mock cloud is logged in.""" + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03', + 'cognito:username': 'abcdefghjkl', + }, 'test') + + async def test_handler_alexa(hass): """Test handler Alexa.""" hass.states.async_set( @@ -197,3 +219,35 @@ async def test_webhook_msg(hass): assert await received[0].json() == { 'hello': 'world' } + + +async def test_google_config_expose_entity( + hass, mock_cloud_setup, mock_cloud_login): + """Test Google config exposing entity method uses latest config.""" + cloud_client = hass.data[DOMAIN].client + state = State('light.kitchen', 'on') + + assert cloud_client.google_config.should_expose(state) + + await cloud_client.prefs.async_update_google_entity_config( + entity_id='light.kitchen', + should_expose=False, + ) + + assert not cloud_client.google_config.should_expose(state) + + +async def test_google_config_should_2fa( + hass, mock_cloud_setup, mock_cloud_login): + """Test Google config disabling 2FA method uses latest config.""" + cloud_client = hass.data[DOMAIN].client + state = State('light.kitchen', 'on') + + assert cloud_client.google_config.should_2fa(state) + + await cloud_client.prefs.async_update_google_entity_config( + entity_id='light.kitchen', + disable_2fa=True, + ) + + assert not cloud_client.google_config.should_2fa(state) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 4aebc5679a0..5ccaba14be6 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -7,10 +7,13 @@ from jose import jwt from hass_nabucasa.auth import Unauthenticated, UnknownError from hass_nabucasa.const import STATE_CONNECTED +from homeassistant.core import State from homeassistant.auth.providers import trusted_networks as tn_auth from homeassistant.components.cloud.const import ( PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_SECURE_DEVICES_PIN, DOMAIN) +from homeassistant.components.google_assistant.helpers import ( + GoogleEntity, Config) from tests.common import mock_coro @@ -32,7 +35,8 @@ def mock_cloud_login(hass, setup_api): """Mock cloud is logged in.""" hass.data[DOMAIN].id_token = jwt.encode({ 'email': 'hello@home-assistant.io', - 'custom:sub-exp': '2018-01-03' + 'custom:sub-exp': '2018-01-03', + 'cognito:username': 'abcdefghjkl', }, 'test') @@ -349,7 +353,15 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture, 'logged_in': True, 'email': 'hello@home-assistant.io', 'cloud': 'connected', - 'prefs': mock_cloud_fixture, + 'prefs': { + 'alexa_enabled': True, + 'cloud_user': None, + 'cloudhooks': {}, + 'google_enabled': True, + 'google_entity_configs': {}, + 'google_secure_devices_pin': None, + 'remote_enabled': False, + }, 'alexa_entities': { 'include_domains': [], 'include_entities': ['light.kitchen', 'switch.ac'], @@ -363,7 +375,6 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture, 'exclude_domains': [], 'exclude_entities': [], }, - 'google_domains': ['light'], 'remote_domain': None, 'remote_connected': False, 'remote_certificate': None, @@ -689,3 +700,52 @@ async def test_enabling_remote_trusted_networks_other( assert cloud.client.remote_autostart assert len(mock_connect.mock_calls) == 1 + + +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' + )) + with patch('homeassistant.components.google_assistant.helpers' + '.async_get_entities', return_value=[entity]): + await client.send_json({ + 'id': 5, + 'type': 'cloud/google_assistant/entities', + }) + response = await client.receive_json() + + assert response['success'] + assert len(response['result']) == 1 + assert response['result'][0] == { + 'entity_id': 'light.kitchen', + 'might_2fa': False, + 'traits': ['action.devices.traits.OnOff'], + } + + +async def test_update_google_entity( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test that we can update config of a Google entity.""" + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'cloud/google_assistant/entities/update', + 'entity_id': 'light.kitchen', + 'should_expose': False, + 'override_name': 'updated name', + 'aliases': ['lefty', 'righty'], + 'disable_2fa': False, + }) + response = await client.receive_json() + + assert response['success'] + prefs = hass.data[DOMAIN].client.prefs + assert prefs.google_entity_configs['light.kitchen'] == { + 'should_expose': False, + 'override_name': 'updated name', + 'aliases': ['lefty', 'righty'], + 'disable_2fa': False, + } diff --git a/tests/components/cloud/test_utils.py b/tests/components/cloud/test_utils.py index 24de4ce6214..4543f6d5623 100644 --- a/tests/components/cloud/test_utils.py +++ b/tests/components/cloud/test_utils.py @@ -14,6 +14,40 @@ def test_serialize_text(): } +def test_serialize_body_str(): + """Test serializing a response with a str as body.""" + response = web.Response(status=201, body='Hello') + assert utils.aiohttp_serialize_response(response) == { + 'status': 201, + 'body': 'Hello', + 'headers': { + 'Content-Length': '5', + 'Content-Type': 'text/plain; charset=utf-8' + }, + } + + +def test_serialize_body_None(): + """Test serializing a response with a str as body.""" + response = web.Response(status=201, body=None) + assert utils.aiohttp_serialize_response(response) == { + 'status': 201, + 'body': None, + 'headers': { + }, + } + + +def test_serialize_body_bytes(): + """Test serializing a response with a str as body.""" + response = web.Response(status=201, body=b'Hello') + assert utils.aiohttp_serialize_response(response) == { + 'status': 201, + 'body': 'Hello', + 'headers': {}, + } + + def test_serialize_json(): """Test serializing a JSON response.""" response = web.json_response({"how": "what"}) diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index f97559a224f..30b4f72b0bc 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -134,3 +134,41 @@ async def test_bad_formatted_automations(hass, hass_client): 'condition': [], 'action': [], } + + +async def test_delete_automation(hass, hass_client): + """Test deleting an automation.""" + with patch.object(config, 'SECTIONS', ['automation']): + await async_setup_component(hass, 'config', {}) + + client = await hass_client() + + orig_data = [ + { + 'id': 'sun', + }, + { + 'id': 'moon', + } + ] + + def mock_read(path): + """Mock reading data.""" + return orig_data + + written = [] + + def mock_write(path, data): + """Mock writing data.""" + written.append(data) + + with patch('homeassistant.components.config._read', mock_read), \ + patch('homeassistant.components.config._write', mock_write): + resp = await client.delete('/api/config/automation/config/sun') + + assert resp.status == 200 + result = await resp.json() + assert result == {'result': 'ok'} + + assert len(written) == 1 + assert written[0][0]['id'] == 'moon' diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 1b5a40ade8a..cdce7433398 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -12,9 +12,11 @@ from homeassistant.config_entries import HANDLERS from homeassistant.core import callback from homeassistant.setup import async_setup_component from homeassistant.components.config import config_entries +from homeassistant.generated import config_flows from tests.common import ( - MockConfigEntry, MockModule, mock_coro_func, mock_integration) + MockConfigEntry, MockModule, mock_coro_func, mock_integration, + mock_entity_platform) @pytest.fixture(autouse=True) @@ -121,7 +123,7 @@ async def test_remove_entry_unauth(hass, client, hass_admin_user): @asyncio.coroutine def test_available_flows(hass, client): """Test querying the available flows.""" - with patch.object(core_ce, 'FLOWS', ['hello', 'world']): + with patch.object(config_flows, 'FLOWS', ['hello', 'world']): resp = yield from client.get( '/api/config/config_entries/flow_handlers') assert resp.status == 200 @@ -137,6 +139,8 @@ def test_available_flows(hass, client): @asyncio.coroutine def test_initialize_flow(hass, client): """Test we can initialize a flow.""" + mock_entity_platform(hass, 'config_flow.test', None) + class TestFlow(core_ce.ConfigFlow): @asyncio.coroutine def async_step_user(self, user_input=None): @@ -221,6 +225,8 @@ async def test_initialize_flow_unauth(hass, client, hass_admin_user): @asyncio.coroutine def test_abort(hass, client): """Test a flow that aborts.""" + mock_entity_platform(hass, 'config_flow.test', None) + class TestFlow(core_ce.ConfigFlow): @asyncio.coroutine def async_step_user(self, user_input=None): @@ -244,6 +250,8 @@ def test_abort(hass, client): @asyncio.coroutine def test_create_account(hass, client): """Test a flow that creates an account.""" + mock_entity_platform(hass, 'config_flow.test', None) + mock_integration( hass, MockModule('test', async_setup_entry=mock_coro_func(True))) @@ -286,6 +294,7 @@ def test_two_step_flow(hass, client): mock_integration( hass, MockModule('test', async_setup_entry=mock_coro_func(True))) + mock_entity_platform(hass, 'config_flow.test', None) class TestFlow(core_ce.ConfigFlow): VERSION = 1 @@ -352,6 +361,7 @@ async def test_continue_flow_unauth(hass, client, hass_admin_user): mock_integration( hass, MockModule('test', async_setup_entry=mock_coro_func(True))) + mock_entity_platform(hass, 'config_flow.test', None) class TestFlow(core_ce.ConfigFlow): VERSION = 1 @@ -402,6 +412,8 @@ async def test_continue_flow_unauth(hass, client, hass_admin_user): @asyncio.coroutine def test_get_progress_index(hass, client): """Test querying for the flows that are in progress.""" + mock_entity_platform(hass, 'config_flow.test', None) + class TestFlow(core_ce.ConfigFlow): VERSION = 5 @@ -441,6 +453,8 @@ async def test_get_progress_index_unauth(hass, client, hass_admin_user): @asyncio.coroutine def test_get_progress_flow(hass, client): """Test we can query the API for same result as we get from init a flow.""" + mock_entity_platform(hass, 'config_flow.test', None) + class TestFlow(core_ce.ConfigFlow): @asyncio.coroutine def async_step_user(self, user_input=None): @@ -474,6 +488,8 @@ def test_get_progress_flow(hass, client): async def test_get_progress_flow_unauth(hass, client, hass_admin_user): """Test we can can't query the API for result of flow.""" + mock_entity_platform(hass, 'config_flow.test', None) + class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): schema = OrderedDict() diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index 4d9063d774b..e58971a4cd8 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -1,38 +1,167 @@ """Test hassbian config.""" -import asyncio from unittest.mock import patch +import pytest + from homeassistant.bootstrap import async_setup_component from homeassistant.components import config +from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.const import CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL +from homeassistant.util import dt as dt_util, location from tests.common import mock_coro +ORIG_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE -@asyncio.coroutine -def test_validate_config_ok(hass, hass_client): + +@pytest.fixture +async def client(hass, hass_ws_client): + """Fixture that can interact with the config manager API.""" + with patch.object(config, 'SECTIONS', ['core']): + assert await async_setup_component(hass, 'config', {}) + return await hass_ws_client(hass) + + +async def test_validate_config_ok(hass, hass_client): """Test checking config.""" with patch.object(config, 'SECTIONS', ['core']): - yield from async_setup_component(hass, 'config', {}) + await async_setup_component(hass, 'config', {}) - yield from asyncio.sleep(0.1, loop=hass.loop) - - client = yield from hass_client() + client = await hass_client() with patch( 'homeassistant.components.config.core.async_check_ha_config_file', return_value=mock_coro()): - resp = yield from client.post('/api/config/core/check_config') + resp = await client.post('/api/config/core/check_config') assert resp.status == 200 - result = yield from resp.json() + result = await resp.json() assert result['result'] == 'valid' assert result['errors'] is None with patch( 'homeassistant.components.config.core.async_check_ha_config_file', return_value=mock_coro('beer')): - resp = yield from client.post('/api/config/core/check_config') + resp = await client.post('/api/config/core/check_config') assert resp.status == 200 - result = yield from resp.json() + result = await resp.json() assert result['result'] == 'invalid' assert result['errors'] == 'beer' + + +async def test_websocket_core_update(hass, client): + """Test core config update websocket command.""" + assert hass.config.latitude != 60 + assert hass.config.longitude != 50 + assert hass.config.elevation != 25 + assert hass.config.location_name != 'Huis' + assert hass.config.units.name != CONF_UNIT_SYSTEM_IMPERIAL + assert hass.config.time_zone.zone != 'America/New_York' + + await client.send_json({ + 'id': 5, + 'type': 'config/core/update', + 'latitude': 60, + 'longitude': 50, + 'elevation': 25, + 'location_name': 'Huis', + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + 'time_zone': 'America/New_York', + }) + + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + assert hass.config.latitude == 60 + assert hass.config.longitude == 50 + assert hass.config.elevation == 25 + assert hass.config.location_name == 'Huis' + assert hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL + assert hass.config.time_zone.zone == 'America/New_York' + + dt_util.set_default_time_zone(ORIG_TIME_ZONE) + + +async def test_websocket_core_update_not_admin( + hass, hass_ws_client, hass_admin_user): + """Test core config fails for non admin.""" + hass_admin_user.groups = [] + with patch.object(config, 'SECTIONS', ['core']): + await async_setup_component(hass, 'config', {}) + + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 6, + 'type': 'config/core/update', + 'latitude': 23, + }) + + msg = await client.receive_json() + + assert msg['id'] == 6 + assert msg['type'] == TYPE_RESULT + assert not msg['success'] + assert msg['error']['code'] == 'unauthorized' + + +async def test_websocket_bad_core_update(hass, client): + """Test core config update fails with bad parameters.""" + await client.send_json({ + 'id': 7, + 'type': 'config/core/update', + 'latituude': 23, + }) + + msg = await client.receive_json() + + assert msg['id'] == 7 + assert msg['type'] == TYPE_RESULT + assert not msg['success'] + assert msg['error']['code'] == 'invalid_format' + + +async def test_detect_config(hass, client): + """Test detect config.""" + with patch('homeassistant.util.location.async_detect_location_info', + return_value=mock_coro(None)): + await client.send_json({ + 'id': 1, + 'type': 'config/core/detect', + }) + + msg = await client.receive_json() + + assert msg['success'] is True + assert msg['result'] == {} + + +async def test_detect_config_fail(hass, client): + """Test detect config.""" + with patch('homeassistant.util.location.async_detect_location_info', + return_value=mock_coro(location.LocationInfo( + ip=None, + country_code=None, + country_name=None, + region_code=None, + region_name=None, + city=None, + zip_code=None, + latitude=None, + longitude=None, + use_metric=True, + time_zone='Europe/Amsterdam', + ))): + await client.send_json({ + 'id': 1, + 'type': 'config/core/detect', + }) + + msg = await client.receive_json() + + assert msg['success'] is True + assert msg['result'] == { + 'unit_system': 'metric', + 'time_zone': 'Europe/Amsterdam', + } diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py new file mode 100644 index 00000000000..d0848d18dcc --- /dev/null +++ b/tests/components/config/test_script.py @@ -0,0 +1,41 @@ +"""Tests for config/script.""" +from unittest.mock import patch + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components import config + + +async def test_delete_script(hass, hass_client): + """Test deleting a script.""" + with patch.object(config, 'SECTIONS', ['script']): + await async_setup_component(hass, 'config', {}) + + client = await hass_client() + + orig_data = { + 'one': {}, + 'two': {}, + } + + def mock_read(path): + """Mock reading data.""" + return orig_data + + written = [] + + def mock_write(path, data): + """Mock writing data.""" + written.append(data) + + with patch('homeassistant.components.config._read', mock_read), \ + patch('homeassistant.components.config._write', mock_write): + resp = await client.delete('/api/config/script/config/two') + + assert resp.status == 200 + result = await resp.json() + assert result == {'result': 'ok'} + + assert len(written) == 1 + assert written[0] == { + 'one': {} + } diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py index 1aee53f43c2..89629a07cfa 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -1,6 +1,8 @@ """deCONZ binary sensor platform tests.""" from unittest.mock import Mock, patch +from tests.common import mock_coro + from homeassistant import config_entries from homeassistant.components import deconz from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -8,8 +10,6 @@ from homeassistant.setup import async_setup_component import homeassistant.components.binary_sensor as binary_sensor -from tests.common import mock_coro - SENSOR = { "1": { @@ -104,6 +104,7 @@ async def test_add_new_sensor(hass): sensor = Mock() sensor.name = 'name' sensor.type = 'ZHAPresence' + sensor.BINARY = True sensor.register_async_callback = Mock() async_dispatcher_send( hass, gateway.async_event_new_device('sensor'), [sensor]) diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py index a5f4d2bb79b..407f5d92871 100644 --- a/tests/components/deconz/test_climate.py +++ b/tests/components/deconz/test_climate.py @@ -1,4 +1,5 @@ """deCONZ climate platform tests.""" +from copy import deepcopy from unittest.mock import Mock, patch import asynctest @@ -18,9 +19,9 @@ SENSOR = { "id": "Climate 1 id", "name": "Climate 1 name", "type": "ZHAThermostat", - "state": {"on": True, "temperature": 2260}, + "state": {"on": True, "temperature": 2260, "valve": 30}, "config": {"battery": 100, "heatsetpoint": 2200, "mode": "auto", - "offset": 10, "reachable": True, "valve": 30}, + "offset": 10, "reachable": True}, "uniqueid": "00:00:00:00:00:00:00:00-00" }, "2": { @@ -97,7 +98,7 @@ async def test_no_sensors(hass): async def test_climate_devices(hass): """Test successful creation of sensor entities.""" - gateway = await setup_gateway(hass, {"sensors": SENSOR}) + gateway = await setup_gateway(hass, {"sensors": deepcopy(SENSOR)}) assert "climate.climate_1_name" in gateway.deconz_ids assert "sensor.sensor_2_name" not in gateway.deconz_ids assert len(hass.states.async_all()) == 1 @@ -138,7 +139,7 @@ async def test_climate_devices(hass): async def test_verify_state_update(hass): """Test that state update properly.""" - gateway = await setup_gateway(hass, {"sensors": SENSOR}) + gateway = await setup_gateway(hass, {"sensors": deepcopy(SENSOR)}) assert "climate.climate_1_name" in gateway.deconz_ids thermostat = hass.states.get('climate.climate_1_name') @@ -149,7 +150,7 @@ async def test_verify_state_update(hass): "e": "changed", "r": "sensors", "id": "1", - "config": {"on": False} + "state": {"on": False} } gateway.api.async_event_handler(state_update) @@ -158,6 +159,8 @@ async def test_verify_state_update(hass): thermostat = hass.states.get('climate.climate_1_name') assert thermostat.state == 'off' + assert gateway.api.sensors['1'].changed_keys == \ + {'state', 'r', 't', 'on', 'e', 'id'} async def test_add_new_climate_device(hass): diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index ada506be428..2b9f2c013b0 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -43,7 +43,7 @@ async def test_flow_works(hass, aioclient_mock): async def test_user_step_bridge_discovery_fails(hass, aioclient_mock): """Test config flow works when discovery fails.""" - with patch('pydeconz.utils.async_discovery', + with patch('homeassistant.components.deconz.config_flow.async_discovery', side_effect=asyncio.TimeoutError): result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, @@ -158,8 +158,9 @@ async def test_link_no_api_key(hass): config_flow.CONF_PORT: 80 } - with patch('pydeconz.utils.async_get_api_key', - side_effect=pydeconz.errors.ResponseError): + with patch( + 'homeassistant.components.deconz.config_flow.async_get_api_key', + side_effect=pydeconz.errors.ResponseError): result = await flow.async_step_link(user_input={}) assert result['type'] == 'form' @@ -167,22 +168,38 @@ async def test_link_no_api_key(hass): assert result['errors'] == {'base': 'no_key'} -async def test_bridge_discovery(hass): - """Test a bridge being discovered.""" +async def test_bridge_ssdp_discovery(hass): + """Test a bridge being discovered over ssdp.""" result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, data={ config_flow.CONF_HOST: '1.2.3.4', config_flow.CONF_PORT: 80, - config_flow.CONF_SERIAL: 'id', + config_flow.ATTR_SERIAL: 'id', + config_flow.ATTR_MANUFACTURERURL: + config_flow.DECONZ_MANUFACTURERURL }, - context={'source': 'discovery'} + context={'source': 'ssdp'} ) assert result['type'] == 'form' assert result['step_id'] == 'link' +async def test_bridge_ssdp_discovery_not_deconz_bridge(hass): + """Test a non deconz bridge being discovered over ssdp.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + data={ + config_flow.ATTR_MANUFACTURERURL: 'not deconz bridge' + }, + context={'source': 'ssdp'} + ) + + assert result['type'] == 'abort' + assert result['reason'] == 'not_deconz_bridge' + + async def test_bridge_discovery_update_existing_entry(hass): """Test if a discovered bridge has already been configured.""" entry = MockConfigEntry(domain=config_flow.DOMAIN, data={ @@ -194,9 +211,11 @@ async def test_bridge_discovery_update_existing_entry(hass): config_flow.DOMAIN, data={ config_flow.CONF_HOST: 'mock-deconz', - config_flow.CONF_SERIAL: 'id', + config_flow.ATTR_SERIAL: 'id', + config_flow.ATTR_MANUFACTURERURL: + config_flow.DECONZ_MANUFACTURERURL }, - context={'source': 'discovery'} + context={'source': 'ssdp'} ) assert result['type'] == 'abort' @@ -275,8 +294,9 @@ async def test_create_entry_timeout(hass, aioclient_mock): config_flow.CONF_API_KEY: '1234567890ABCDEF' } - with patch('pydeconz.utils.async_get_bridgeid', - side_effect=asyncio.TimeoutError): + with patch( + 'homeassistant.components.deconz.config_flow.async_get_bridgeid', + side_effect=asyncio.TimeoutError): result = await flow._create_entry() assert result['type'] == 'abort' diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 6006ff66898..46107e1dd6c 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -223,7 +223,8 @@ async def test_update_event(): remote.name = 'Name' event = gateway.DeconzEvent(hass, remote) - event.async_update_callback({'state': True}) + remote.changed_keys = {'state': True} + event.async_update_callback() assert len(hass.bus.async_fire.mock_calls) == 1 diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 41bb7b362f5..7ed8bef093e 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -1,6 +1,8 @@ """deCONZ sensor platform tests.""" from unittest.mock import Mock, patch +from tests.common import mock_coro + from homeassistant import config_entries from homeassistant.components import deconz from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -8,8 +10,6 @@ from homeassistant.setup import async_setup_component import homeassistant.components.sensor as sensor -from tests.common import mock_coro - SENSOR = { "1": { @@ -142,6 +142,7 @@ async def test_add_new_sensor(hass): sensor = Mock() sensor.name = 'name' sensor.type = 'ZHATemperature' + sensor.BINARY = False sensor.register_async_callback = Mock() async_dispatcher_send( hass, gateway.async_event_new_device('sensor'), [sensor]) diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py index 94adf53cb2d..5aacf06aa66 100644 --- a/tests/components/default_config/test_init.py +++ b/tests/components/default_config/test_init.py @@ -5,7 +5,16 @@ from homeassistant.setup import async_setup_component import pytest -from tests.common import MockDependency +from tests.common import MockDependency, mock_coro + + +@pytest.fixture(autouse=True) +def zeroconf_mock(): + """Mock zeroconf.""" + with MockDependency('zeroconf') as mocked_zeroconf: + mocked_zeroconf.Zeroconf.return_value.register_service \ + .return_value = mock_coro(True) + yield @pytest.fixture(autouse=True) diff --git a/tests/components/demo/test_init.py b/tests/components/demo/test_init.py index fde2caecff4..cacc29cc5d5 100644 --- a/tests/components/demo/test_init.py +++ b/tests/components/demo/test_init.py @@ -5,7 +5,8 @@ import os import pytest from homeassistant.setup import async_setup_component -from homeassistant.components import demo, device_tracker +from homeassistant.components import demo +from homeassistant.components.device_tracker.legacy import YAML_DEVICES from homeassistant.helpers.json import JSONEncoder @@ -20,7 +21,7 @@ def demo_cleanup(hass): """Clean up device tracker demo file.""" yield try: - os.remove(hass.config.path(device_tracker.YAML_DEVICES)) + os.remove(hass.config.path(YAML_DEVICES)) except FileNotFoundError: pass diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index d4356ace48c..547ef74a0fd 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -8,6 +8,8 @@ from homeassistant.setup import async_setup_component from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME from homeassistant.components import ( device_tracker, light, device_sun_light_trigger) +from homeassistant.components.device_tracker.const import ( + ENTITY_ID_FORMAT as DT_ENTITY_ID_FORMAT) from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -26,7 +28,7 @@ def scanner(hass): getattr(hass.components, 'test.light').init() with patch( - 'homeassistant.components.device_tracker.load_yaml_config_file', + 'homeassistant.components.device_tracker.legacy.load_yaml_config_file', return_value={ 'device_1': { 'hide_if_away': False, @@ -102,7 +104,7 @@ async def test_lights_turn_on_when_coming_home_after_sun_set(hass, scanner): device_sun_light_trigger.DOMAIN: {}}) hass.states.async_set( - device_tracker.ENTITY_ID_FORMAT.format('device_2'), STATE_HOME) + DT_ENTITY_ID_FORMAT.format('device_2'), STATE_HOME) await hass.async_block_till_done() assert light.is_on(hass) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index e2648c1c650..9a59855e8c1 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -10,9 +10,11 @@ import pytest from homeassistant.components import zone 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_ICON, CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME, + ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_GPS_ACCURACY) from homeassistant.core import State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import discovery @@ -33,7 +35,7 @@ _LOGGER = logging.getLogger(__name__) @pytest.fixture(name='yaml_devices') def mock_yaml_devices(hass): """Get a path for storing yaml devices.""" - yaml_devices = hass.config.path(device_tracker.YAML_DEVICES) + yaml_devices = hass.config.path(legacy.YAML_DEVICES) if os.path.isfile(yaml_devices): os.remove(yaml_devices) yield yaml_devices @@ -43,7 +45,7 @@ def mock_yaml_devices(hass): async def test_is_on(hass): """Test is_on method.""" - entity_id = device_tracker.ENTITY_ID_FORMAT.format('test') + entity_id = const.ENTITY_ID_FORMAT.format('test') hass.states.async_set(entity_id, STATE_HOME) @@ -65,21 +67,21 @@ async def test_reading_broken_yaml_config(hass): 'bad_device:\n nme: Device')} args = {'hass': hass, 'consider_home': timedelta(seconds=60)} with patch_yaml_files(files): - assert await device_tracker.async_load_config( + assert await legacy.async_load_config( 'empty.yaml', **args) == [] - assert await device_tracker.async_load_config( + assert await legacy.async_load_config( 'nodict.yaml', **args) == [] - assert await device_tracker.async_load_config( + assert await legacy.async_load_config( 'noname.yaml', **args) == [] - assert await device_tracker.async_load_config( + assert await legacy.async_load_config( 'badkey.yaml', **args) == [] - res = await device_tracker.async_load_config('allok.yaml', **args) + res = await legacy.async_load_config('allok.yaml', **args) assert len(res) == 1 assert res[0].name == 'Device' assert res[0].dev_id == 'my_device' - res = await device_tracker.async_load_config('oneok.yaml', **args) + res = await legacy.async_load_config('oneok.yaml', **args) assert len(res) == 1 assert res[0].name == 'Device' assert res[0].dev_id == 'my_device' @@ -88,17 +90,16 @@ async def test_reading_broken_yaml_config(hass): async def test_reading_yaml_config(hass, yaml_devices): """Test the rendering of the YAML configuration.""" dev_id = 'test' - device = device_tracker.Device( + device = legacy.Device( hass, timedelta(seconds=180), True, dev_id, 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture', hide_if_away=True, icon='mdi:kettle') await hass.async_add_executor_job( - device_tracker.update_config, yaml_devices, dev_id, device) - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, - TEST_PLATFORM) - config = (await device_tracker.async_load_config(yaml_devices, hass, - device.consider_home))[0] + legacy.update_config, yaml_devices, dev_id, device) + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + config = (await legacy.async_load_config(yaml_devices, hass, + device.consider_home))[0] assert device.dev_id == config.dev_id assert device.track == config.track assert device.mac == config.mac @@ -108,15 +109,15 @@ async def test_reading_yaml_config(hass, yaml_devices): assert device.icon == config.icon -@patch('homeassistant.components.device_tracker._LOGGER.warning') +@patch('homeassistant.components.device_tracker.const.LOGGER.warning') async def test_duplicate_mac_dev_id(mock_warning, hass): """Test adding duplicate MACs or device IDs to DeviceTracker.""" devices = [ - device_tracker.Device(hass, True, True, 'my_device', 'AB:01', - 'My device', None, None, False), - device_tracker.Device(hass, True, True, 'your_device', - 'AB:01', 'Your device', None, None, False)] - device_tracker.DeviceTracker(hass, False, True, {}, devices) + legacy.Device(hass, True, True, 'my_device', 'AB:01', + 'My device', None, None, False), + legacy.Device(hass, True, True, 'your_device', + 'AB:01', 'Your device', None, None, False)] + legacy.DeviceTracker(hass, False, True, {}, devices) _LOGGER.debug(mock_warning.call_args_list) assert mock_warning.call_count == 1, \ "The only warning call should be duplicates (check DEBUG)" @@ -126,11 +127,11 @@ async def test_duplicate_mac_dev_id(mock_warning, hass): mock_warning.reset_mock() devices = [ - device_tracker.Device(hass, True, True, 'my_device', - 'AB:01', 'My device', None, None, False), - device_tracker.Device(hass, True, True, 'my_device', - None, 'Your device', None, None, False)] - device_tracker.DeviceTracker(hass, False, True, {}, devices) + legacy.Device(hass, True, True, 'my_device', + 'AB:01', 'My device', None, None, False), + legacy.Device(hass, True, True, 'my_device', + None, 'Your device', None, None, False)] + legacy.DeviceTracker(hass, False, True, {}, devices) _LOGGER.debug(mock_warning.call_args_list) assert mock_warning.call_count == 1, \ @@ -150,7 +151,7 @@ async def test_setup_without_yaml_file(hass): async def test_gravatar(hass): """Test the Gravatar generation.""" dev_id = 'test' - device = device_tracker.Device( + device = legacy.Device( hass, timedelta(seconds=180), True, dev_id, 'AB:CD:EF:GH:IJ', 'Test name', gravatar='test@example.com') gravatar_url = ("https://www.gravatar.com/avatar/" @@ -161,7 +162,7 @@ async def test_gravatar(hass): async def test_gravatar_and_picture(hass): """Test that Gravatar overrides picture.""" dev_id = 'test' - device = device_tracker.Device( + device = legacy.Device( hass, timedelta(seconds=180), True, dev_id, 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture', gravatar='test@example.com') @@ -171,7 +172,7 @@ async def test_gravatar_and_picture(hass): @patch( - 'homeassistant.components.device_tracker.DeviceTracker.see') + 'homeassistant.components.device_tracker.legacy.DeviceTracker.see') @patch( 'homeassistant.components.demo.device_tracker.setup_scanner', autospec=True) @@ -196,7 +197,7 @@ async def test_update_stale(hass, mock_device_tracker_conf): register_time = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) scan_time = datetime(2015, 9, 15, 23, 1, tzinfo=dt_util.UTC) - with patch('homeassistant.components.device_tracker.dt_util.utcnow', + with patch('homeassistant.components.device_tracker.legacy.dt_util.utcnow', return_value=register_time): with assert_setup_component(1, device_tracker.DOMAIN): assert await async_setup_component(hass, device_tracker.DOMAIN, { @@ -211,7 +212,7 @@ async def test_update_stale(hass, mock_device_tracker_conf): scanner.leave_home('DEV1') - with patch('homeassistant.components.device_tracker.dt_util.utcnow', + with patch('homeassistant.components.device_tracker.legacy.dt_util.utcnow', return_value=scan_time): async_fire_time_changed(hass, scan_time) await hass.async_block_till_done() @@ -224,12 +225,12 @@ async def test_entity_attributes(hass, mock_device_tracker_conf): """Test the entity attributes.""" devices = mock_device_tracker_conf dev_id = 'test_entity' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + entity_id = const.ENTITY_ID_FORMAT.format(dev_id) friendly_name = 'Paulus' picture = 'http://placehold.it/200x200' icon = 'mdi:kettle' - device = device_tracker.Device( + device = legacy.Device( hass, timedelta(seconds=180), True, dev_id, None, friendly_name, picture, hide_if_away=True, icon=icon) devices.append(device) @@ -249,8 +250,8 @@ async def test_device_hidden(hass, mock_device_tracker_conf): """Test hidden devices.""" devices = mock_device_tracker_conf dev_id = 'test_entity' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - device = device_tracker.Device( + entity_id = const.ENTITY_ID_FORMAT.format(dev_id) + device = legacy.Device( hass, timedelta(seconds=180), True, dev_id, None, hide_if_away=True) devices.append(device) @@ -269,8 +270,8 @@ async def test_group_all_devices(hass, mock_device_tracker_conf): """Test grouping of devices.""" devices = mock_device_tracker_conf dev_id = 'test_entity' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - device = device_tracker.Device( + entity_id = const.ENTITY_ID_FORMAT.format(dev_id) + device = legacy.Device( hass, timedelta(seconds=180), True, dev_id, None, hide_if_away=True) devices.append(device) @@ -288,7 +289,8 @@ async def test_group_all_devices(hass, mock_device_tracker_conf): assert (entity_id,) == state.attributes.get(ATTR_ENTITY_ID) -@patch('homeassistant.components.device_tracker.DeviceTracker.async_see') +@patch('homeassistant.components.device_tracker.legacy.' + 'DeviceTracker.async_see') async def test_see_service(mock_see, hass): """Test the see service with a unicode dev_id and NO MAC.""" with assert_setup_component(1, device_tracker.DOMAIN): @@ -401,8 +403,8 @@ async def test_see_state(hass, yaml_devices): common.async_see(hass, **params) await hass.async_block_till_done() - config = await device_tracker.async_load_config(yaml_devices, hass, - timedelta(seconds=0)) + config = await legacy.async_load_config( + yaml_devices, hass, timedelta(seconds=0)) assert len(config) == 1 state = hass.states.get('device_tracker.example_com') @@ -442,7 +444,7 @@ async def test_see_passive_zone_state(hass, mock_device_tracker_conf): scanner.reset() scanner.come_home('dev1') - with patch('homeassistant.components.device_tracker.dt_util.utcnow', + with patch('homeassistant.components.device_tracker.legacy.dt_util.utcnow', return_value=register_time): with assert_setup_component(1, device_tracker.DOMAIN): assert await async_setup_component(hass, device_tracker.DOMAIN, { @@ -466,7 +468,7 @@ async def test_see_passive_zone_state(hass, mock_device_tracker_conf): scanner.leave_home('dev1') - with patch('homeassistant.components.device_tracker.dt_util.utcnow', + with patch('homeassistant.components.device_tracker.legacy.dt_util.utcnow', return_value=scan_time): async_fire_time_changed(hass, scan_time) await hass.async_block_till_done() @@ -484,11 +486,11 @@ async def test_see_passive_zone_state(hass, mock_device_tracker_conf): device_tracker.SOURCE_TYPE_ROUTER -@patch('homeassistant.components.device_tracker._LOGGER.warning') +@patch('homeassistant.components.device_tracker.const.LOGGER.warning') async def test_see_failures(mock_warning, hass, mock_device_tracker_conf): """Test that the device tracker see failures.""" devices = mock_device_tracker_conf - tracker = device_tracker.DeviceTracker( + tracker = legacy.DeviceTracker( hass, timedelta(seconds=60), 0, {}, []) # MAC is not a string (but added) @@ -512,16 +514,15 @@ async def test_see_failures(mock_warning, hass, mock_device_tracker_conf): async def test_async_added_to_hass(hass): """Test restoring state.""" attr = { - device_tracker.ATTR_LONGITUDE: 18, - device_tracker.ATTR_LATITUDE: -33, - device_tracker.ATTR_LATITUDE: -33, - device_tracker.ATTR_SOURCE_TYPE: 'gps', - device_tracker.ATTR_GPS_ACCURACY: 2, - device_tracker.ATTR_BATTERY: 100 + ATTR_LONGITUDE: 18, + ATTR_LATITUDE: -33, + const.ATTR_SOURCE_TYPE: 'gps', + ATTR_GPS_ACCURACY: 2, + const.ATTR_BATTERY: 100 } mock_restore_cache(hass, [State('device_tracker.jk', 'home', attr)]) - path = hass.config.path(device_tracker.YAML_DEVICES) + path = hass.config.path(legacy.YAML_DEVICES) files = { path: 'jk:\n name: JK Phone\n track: True', @@ -570,7 +571,7 @@ async def test_adding_unknown_device_to_config(mock_device_tracker_conf, hass): async def test_picture_and_icon_on_see_discovery(mock_device_tracker_conf, hass): """Test that picture and icon are set in initial see.""" - tracker = device_tracker.DeviceTracker( + tracker = legacy.DeviceTracker( hass, timedelta(seconds=60), False, {}, []) await tracker.async_see(dev_id=11, picture='pic_url', icon='mdi:icon') await hass.async_block_till_done() @@ -581,7 +582,7 @@ async def test_picture_and_icon_on_see_discovery(mock_device_tracker_conf, async def test_default_hide_if_away_is_used(mock_device_tracker_conf, hass): """Test that default track_new is used.""" - tracker = device_tracker.DeviceTracker( + tracker = legacy.DeviceTracker( hass, timedelta(seconds=60), False, {device_tracker.CONF_AWAY_HIDE: True}, []) await tracker.async_see(dev_id=12) @@ -593,7 +594,7 @@ async def test_default_hide_if_away_is_used(mock_device_tracker_conf, hass): async def test_backward_compatibility_for_track_new(mock_device_tracker_conf, hass): """Test backward compatibility for track new.""" - tracker = device_tracker.DeviceTracker( + tracker = legacy.DeviceTracker( hass, timedelta(seconds=60), False, {device_tracker.CONF_TRACK_NEW: True}, []) await tracker.async_see(dev_id=13) @@ -604,7 +605,7 @@ async def test_backward_compatibility_for_track_new(mock_device_tracker_conf, async def test_old_style_track_new_is_skipped(mock_device_tracker_conf, hass): """Test old style config is skipped.""" - tracker = device_tracker.DeviceTracker( + tracker = legacy.DeviceTracker( hass, timedelta(seconds=60), None, {device_tracker.CONF_TRACK_NEW: False}, []) await tracker.async_see(dev_id=14) diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 366d2163818..04bc4414aa7 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -78,7 +78,7 @@ def test_default_setup(hass, mock_connection_factory): telegram_callback(telegram) # after receiving telegram entities need to have the chance to update - yield from asyncio.sleep(0, loop=hass.loop) + yield from asyncio.sleep(0) # ensure entities have new state value after incoming telegram power_consumption = hass.states.get('sensor.power_consumption') @@ -183,9 +183,9 @@ def test_reconnect(hass, monkeypatch, mock_connection_factory): } # mock waiting coroutine while connection lasts - closed = asyncio.Event(loop=hass.loop) + closed = asyncio.Event() # Handshake so that `hass.async_block_till_done()` doesn't cycle forever - closed2 = asyncio.Event(loop=hass.loop) + closed2 = asyncio.Event() @asyncio.coroutine def wait_closed(): diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 076ec0066a6..f991c36c4f0 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.esphome import config_flow +from homeassistant.components.esphome import config_flow, DATA_KEY from tests.common import mock_coro, MockConfigEntry MockDeviceInfo = namedtuple("DeviceInfo", ["uses_password", "name"]) @@ -45,10 +45,16 @@ def mock_api_connection_error(): yield mock_error -async def test_user_connection_works(hass, mock_client): - """Test we can finish a config flow.""" +def _setup_flow_handler(hass): flow = config_flow.EsphomeFlowHandler() flow.hass = hass + flow.context = {} + return flow + + +async def test_user_connection_works(hass, mock_client): + """Test we can finish a config flow.""" + flow = _setup_flow_handler(hass) result = await flow.async_step_user(user_input=None) assert result['type'] == 'form' @@ -78,8 +84,7 @@ async def test_user_connection_works(hass, mock_client): async def test_user_resolve_error(hass, mock_api_connection_error, mock_client): """Test user step with IP resolve error.""" - flow = config_flow.EsphomeFlowHandler() - flow.hass = hass + flow = _setup_flow_handler(hass) await flow.async_step_user(user_input=None) class MockResolveError(mock_api_connection_error): @@ -111,8 +116,7 @@ async def test_user_resolve_error(hass, mock_api_connection_error, async def test_user_connection_error(hass, mock_api_connection_error, mock_client): """Test user step with connection error.""" - flow = config_flow.EsphomeFlowHandler() - flow.hass = hass + flow = _setup_flow_handler(hass) await flow.async_step_user(user_input=None) mock_client.device_info.side_effect = mock_api_connection_error @@ -134,8 +138,7 @@ async def test_user_connection_error(hass, mock_api_connection_error, async def test_user_with_password(hass, mock_client): """Test user step with password.""" - flow = config_flow.EsphomeFlowHandler() - flow.hass = hass + flow = _setup_flow_handler(hass) await flow.async_step_user(user_input=None) mock_client.device_info.return_value = mock_coro( @@ -165,8 +168,7 @@ async def test_user_with_password(hass, mock_client): async def test_user_invalid_password(hass, mock_api_connection_error, mock_client): """Test user step with invalid password.""" - flow = config_flow.EsphomeFlowHandler() - flow.hass = hass + flow = _setup_flow_handler(hass) await flow.async_step_user(user_input=None) mock_client.device_info.return_value = mock_coro( @@ -190,8 +192,7 @@ async def test_user_invalid_password(hass, mock_api_connection_error, async def test_discovery_initiation(hass, mock_client): """Test discovery importing works.""" - flow = config_flow.EsphomeFlowHandler() - flow.hass = hass + flow = _setup_flow_handler(hass) service_info = { 'host': '192.168.43.183', 'port': 6053, @@ -202,10 +203,11 @@ async def test_discovery_initiation(hass, mock_client): mock_client.device_info.return_value = mock_coro( MockDeviceInfo(False, "test8266")) - result = await flow.async_step_discovery(user_input=service_info) + result = await flow.async_step_zeroconf(user_input=service_info) assert result['type'] == 'form' assert result['step_id'] == 'discovery_confirm' assert result['description_placeholders']['name'] == 'test8266' + assert flow.context['title_placeholders']['name'] == 'test8266' result = await flow.async_step_discovery_confirm(user_input={}) assert result['type'] == 'create_entry' @@ -221,15 +223,14 @@ async def test_discovery_already_configured_hostname(hass, mock_client): data={'host': 'test8266.local', 'port': 6053, 'password': ''} ).add_to_hass(hass) - flow = config_flow.EsphomeFlowHandler() - flow.hass = hass + flow = _setup_flow_handler(hass) service_info = { 'host': '192.168.43.183', 'port': 6053, 'hostname': 'test8266.local.', 'properties': {} } - result = await flow.async_step_discovery(user_input=service_info) + result = await flow.async_step_zeroconf(user_input=service_info) assert result['type'] == 'abort' assert result['reason'] == 'already_configured' @@ -241,8 +242,7 @@ async def test_discovery_already_configured_ip(hass, mock_client): data={'host': '192.168.43.183', 'port': 6053, 'password': ''} ).add_to_hass(hass) - flow = config_flow.EsphomeFlowHandler() - flow.hass = hass + flow = _setup_flow_handler(hass) service_info = { 'host': '192.168.43.183', 'port': 6053, @@ -251,6 +251,33 @@ async def test_discovery_already_configured_ip(hass, mock_client): "address": "192.168.43.183" } } - result = await flow.async_step_discovery(user_input=service_info) + result = await flow.async_step_zeroconf(user_input=service_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' + + +async def test_discovery_already_configured_name(hass, mock_client): + """Test discovery aborts if already configured via name.""" + entry = MockConfigEntry( + domain='esphome', + data={'host': '192.168.43.183', 'port': 6053, 'password': ''} + ) + entry.add_to_hass(hass) + mock_entry_data = MagicMock() + mock_entry_data.device_info.name = 'test8266' + hass.data[DATA_KEY] = { + entry.entry_id: mock_entry_data, + } + + flow = _setup_flow_handler(hass) + service_info = { + 'host': '192.168.43.183', + 'port': 6053, + 'hostname': 'test8266.local.', + 'properties': { + "address": "test8266.local" + } + } + result = await flow.async_step_zeroconf(user_input=service_info) assert result['type'] == 'abort' assert result['reason'] == 'already_configured' diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index ee10b986697..c362499db15 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -8,10 +8,10 @@ import pytest from homeassistant.setup import async_setup_component from homeassistant.components.frontend import ( DOMAIN, CONF_JS_VERSION, CONF_THEMES, CONF_EXTRA_HTML_URL, - CONF_EXTRA_HTML_URL_ES5) + CONF_EXTRA_HTML_URL_ES5, EVENT_PANELS_UPDATED) from homeassistant.components.websocket_api.const import TYPE_RESULT -from tests.common import mock_coro +from tests.common import mock_coro, async_capture_events CONFIG_THEMES = { @@ -232,12 +232,21 @@ def test_extra_urls(mock_http_client_with_urls, mock_onboarded): assert text.find('href="https://domain.com/my_extra_url.html"') >= 0 -async def test_get_panels(hass, hass_ws_client): +async def test_get_panels(hass, hass_ws_client, mock_http_client): """Test get_panels command.""" - await async_setup_component(hass, 'frontend', {}) - await hass.components.frontend.async_register_built_in_panel( + events = async_capture_events(hass, EVENT_PANELS_UPDATED) + + resp = await mock_http_client.get('/map') + assert resp.status == 404 + + hass.components.frontend.async_register_built_in_panel( 'map', 'Map', 'mdi:tooltip-account', require_admin=True) + resp = await mock_http_client.get('/map') + assert resp.status == 200 + + assert len(events) == 1 + client = await hass_ws_client(hass) await client.send_json({ 'id': 5, @@ -255,14 +264,21 @@ async def test_get_panels(hass, hass_ws_client): assert msg['result']['map']['title'] == 'Map' assert msg['result']['map']['require_admin'] is True + hass.components.frontend.async_remove_panel('map') + + resp = await mock_http_client.get('/map') + assert resp.status == 404 + + assert len(events) == 2 + async def test_get_panels_non_admin(hass, hass_ws_client, hass_admin_user): """Test get_panels command.""" hass_admin_user.groups = [] await async_setup_component(hass, 'frontend', {}) - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'map', 'Map', 'mdi:tooltip-account', require_admin=True) - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'history', 'History', 'mdi:history') client = await hass_ws_client(hass) diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 98edd8b3af1..884ef125eab 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -5,13 +5,12 @@ from unittest.mock import patch, Mock import pytest from homeassistant import data_entry_flow -from homeassistant.components import zone, geofency +from homeassistant.components import zone from homeassistant.components.geofency import ( - CONF_MOBILE_BEACONS, DOMAIN, TRACKER_UPDATE) + CONF_MOBILE_BEACONS, DOMAIN) from homeassistant.const import ( HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, STATE_NOT_HOME) -from homeassistant.helpers.dispatcher import DATA_DISPATCHER from homeassistant.setup import async_setup_component from homeassistant.util import slugify @@ -125,7 +124,7 @@ async def geofency_client(loop, hass, aiohttp_client): }}) await hass.async_block_till_done() - with patch('homeassistant.components.device_tracker.update_config'): + with patch('homeassistant.components.device_tracker.legacy.update_config'): return await aiohttp_client(hass.http.app) @@ -217,6 +216,12 @@ async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id): 'device_tracker', device_name)).attributes['longitude'] assert NOT_HOME_LONGITUDE == current_longitude + dev_reg = await hass.helpers.device_registry.async_get_registry() + assert len(dev_reg.devices) == 1 + + ent_reg = await hass.helpers.entity_registry.async_get_registry() + assert len(ent_reg.entities) == 1 + async def test_beacon_enter_and_exit_home(hass, geofency_client, webhook_id): """Test iBeacon based zone enter and exit - a.k.a stationary iBeacon.""" @@ -285,9 +290,6 @@ async def test_beacon_enter_and_exit_car(hass, geofency_client, webhook_id): assert STATE_HOME == state_name -@pytest.mark.xfail( - reason='The device_tracker component does not support unloading yet.' -) async def test_load_unload_entry(hass, geofency_client, webhook_id): """Test that the appropriate dispatch signals are added and removed.""" url = '/api/webhook/{}'.format(webhook_id) @@ -297,13 +299,23 @@ async def test_load_unload_entry(hass, geofency_client, webhook_id): await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_ENTER_HOME['device']) - state_name = hass.states.get('{}.{}'.format( - 'device_tracker', device_name)).state - assert STATE_HOME == state_name - assert len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) == 1 + state_1 = hass.states.get('{}.{}'.format('device_tracker', device_name)) + assert STATE_HOME == state_1.state + assert len(hass.data[DOMAIN]['devices']) == 1 entry = hass.config_entries.async_entries(DOMAIN)[0] - assert await geofency.async_unload_entry(hass, entry) + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert not hass.data[DATA_DISPATCHER][TRACKER_UPDATE] + assert len(hass.data[DOMAIN]['devices']) == 0 + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state_2 = hass.states.get('{}.{}'.format('device_tracker', device_name)) + assert state_2 is not None + assert state_1 is not state_2 + + assert STATE_HOME == state_2.state + assert state_2.attributes['latitude'] == HOME_LATITUDE + assert state_2.attributes['longitude'] == HOME_LONGITUDE diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 519a55fbc00..a65387d48a2 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -530,34 +530,6 @@ async def test_unavailable_state_doesnt_sync(hass): } -async def test_empty_name_doesnt_sync(hass): - """Test that an entity with empty name does not sync over.""" - light = DemoLight( - None, ' ', - state=False, - ) - light.hass = hass - light.entity_id = 'light.demo_light' - await light.async_update_ha_state() - - result = await sh.async_handle_message( - hass, BASIC_CONFIG, 'test-agent', - { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.SYNC" - }] - }) - - assert result == { - 'requestId': REQ_ID, - 'payload': { - 'agentUserId': 'test-agent', - 'devices': [] - } - } - - @pytest.mark.parametrize("device_class,google_type", [ ('non_existing_class', 'action.devices.types.SWITCH'), ('switch', 'action.devices.types.SWITCH'), diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 5e6dadf14f4..6b1b6a7c9f4 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -14,6 +14,7 @@ from homeassistant.components import ( media_player, scene, script, + sensor, switch, vacuum, group, @@ -843,6 +844,8 @@ async def test_lock_unlock_lock(hass): assert helpers.get_google_type(lock.DOMAIN, None) is not None assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None) + assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, lock.SUPPORT_OPEN, + None) trt = trait.LockUnlockTrait(hass, State('lock.front_door', lock.STATE_LOCKED), @@ -922,6 +925,13 @@ async def test_lock_unlock_unlock(hass): assert len(calls) == 1 assert err.value.code == const.ERR_CHALLENGE_NOT_SETUP + # Test with 2FA override + with patch('homeassistant.components.google_assistant.helpers' + '.Config.should_2fa', return_value=False): + await trt.execute( + trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': False}, {}) + assert len(calls) == 2 + async def test_fan_speed(hass): """Test FanSpeed trait speed control support for fan domain.""" @@ -1216,6 +1226,8 @@ async def test_openclose_cover_secure(hass, device_class): assert helpers.get_google_type(cover.DOMAIN, device_class) is not None assert trait.OpenCloseTrait.supported( cover.DOMAIN, cover.SUPPORT_SET_POSITION, device_class) + assert trait.OpenCloseTrait.might_2fa( + cover.DOMAIN, cover.SUPPORT_SET_POSITION, device_class) trt = trait.OpenCloseTrait(hass, State('cover.bla', cover.STATE_OPEN, { ATTR_DEVICE_CLASS: device_class, @@ -1369,3 +1381,35 @@ async def test_volume_media_player_relative(hass): ATTR_ENTITY_ID: 'media_player.bla', media_player.ATTR_MEDIA_VOLUME_LEVEL: .5 } + + +async def test_temperature_setting_sensor(hass): + """Test TemperatureSetting trait support for temperature sensor.""" + assert helpers.get_google_type(sensor.DOMAIN, + sensor.DEVICE_CLASS_TEMPERATURE) is not None + assert not trait.TemperatureSettingTrait.supported( + sensor.DOMAIN, + 0, + sensor.DEVICE_CLASS_HUMIDITY + ) + assert trait.TemperatureSettingTrait.supported( + sensor.DOMAIN, + 0, + sensor.DEVICE_CLASS_TEMPERATURE + ) + + hass.config.units.temperature_unit = TEMP_FAHRENHEIT + + trt = trait.TemperatureSettingTrait(hass, State('sensor.test', "70", { + ATTR_DEVICE_CLASS: sensor.DEVICE_CLASS_TEMPERATURE, + }), BASIC_CONFIG) + + assert trt.sync_attributes() == { + 'queryOnlyTemperatureSetting': True, + 'thermostatTemperatureUnit': 'F', + } + + assert trt.query_attributes() == { + 'thermostatTemperatureAmbient': 21.1 + } + hass.config.units.temperature_unit = TEMP_CELSIUS diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index fce93d0a774..dbc283895fc 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -38,7 +38,7 @@ async def gpslogger_client(loop, hass, aiohttp_client): await hass.async_block_till_done() - with patch('homeassistant.components.device_tracker.update_config'): + with patch('homeassistant.components.device_tracker.legacy.update_config'): return await aiohttp_client(hass.http.app) @@ -140,6 +140,12 @@ async def test_enter_and_exit(hass, gpslogger_client, webhook_id): data['device'])).state assert STATE_NOT_HOME == state_name + dev_reg = await hass.helpers.device_registry.async_get_registry() + assert len(dev_reg.devices) == 1 + + ent_reg = await hass.helpers.entity_registry.async_get_registry() + assert len(ent_reg.entities) == 1 + async def test_enter_with_attrs(hass, gpslogger_client, webhook_id): """Test when additional attributes are present.""" @@ -165,13 +171,40 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id): data['device'])) assert state.state == STATE_NOT_HOME assert state.attributes['gps_accuracy'] == 10.5 - assert state.attributes['battery'] == 10.0 + assert state.attributes['battery_level'] == 10.0 assert state.attributes['speed'] == 100.0 assert state.attributes['direction'] == 105.32 assert state.attributes['altitude'] == 102.0 assert state.attributes['provider'] == 'gps' assert state.attributes['activity'] == 'running' + data = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'device': '123', + 'accuracy': 123, + 'battery': 23, + 'speed': 23, + 'direction': 123, + 'altitude': 123, + 'provider': 'gps', + 'activity': 'idle' + } + + req = await gpslogger_client.post(url, data=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])) + assert state.state == STATE_HOME + assert state.attributes['gps_accuracy'] == 123 + assert state.attributes['battery_level'] == 23 + assert state.attributes['speed'] == 23 + assert state.attributes['direction'] == 123 + assert state.attributes['altitude'] == 123 + assert state.attributes['provider'] == 'gps' + assert state.attributes['activity'] == 'idle' + @pytest.mark.xfail( reason='The device_tracker component does not support unloading yet.' diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 11a2ece3442..175a180e4a3 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -2,7 +2,7 @@ from typing import Dict, Sequence from asynctest.mock import Mock, patch as patch -from pyheos import Dispatcher, HeosPlayer, HeosSource, InputSource, const +from pyheos import Dispatcher, Heos, HeosPlayer, HeosSource, InputSource, const import pytest from homeassistant.components.heos import DOMAIN @@ -22,20 +22,23 @@ def config_entry_fixture(): def controller_fixture( players, favorites, input_sources, playlists, change_data, dispatcher): """Create a mock Heos controller fixture.""" - with patch("pyheos.Heos", autospec=True) as mock: - mock_heos = mock.return_value - for player in players.values(): - player.heos = mock_heos - mock_heos.dispatcher = dispatcher - mock_heos.get_players.return_value = players - mock_heos.players = players - mock_heos.get_favorites.return_value = favorites - mock_heos.get_input_sources.return_value = input_sources - mock_heos.get_playlists.return_value = playlists - mock_heos.load_players.return_value = change_data - mock_heos.is_signed_in = True - mock_heos.signed_in_username = "user@user.com" - mock_heos.connection_state = const.STATE_CONNECTED + mock_heos = Mock(Heos) + for player in players.values(): + player.heos = mock_heos + mock_heos.dispatcher = dispatcher + mock_heos.get_players.return_value = players + mock_heos.players = players + mock_heos.get_favorites.return_value = favorites + mock_heos.get_input_sources.return_value = input_sources + mock_heos.get_playlists.return_value = playlists + mock_heos.load_players.return_value = change_data + mock_heos.is_signed_in = True + mock_heos.signed_in_username = "user@user.com" + mock_heos.connection_state = const.STATE_CONNECTED + mock = Mock(return_value=mock_heos) + + with patch("homeassistant.components.heos.Heos", new=mock), \ + patch("homeassistant.components.heos.config_flow.Heos", new=mock): yield mock_heos diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 3c00867a2cf..98b4e2239f0 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -311,4 +311,4 @@ async def test_media_player_television_basic(hass, hk_driver, events, caplog): await hass.async_block_till_done() assert acc.char_active.value == 1 - assert 'Error' not in caplog.messages[-1] + assert not caplog.messages or 'Error' not in caplog.messages[-1] diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 8635e0b6d05..34b6474c6e9 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -9,13 +9,15 @@ from homekit.model.characteristics import ( AbstractCharacteristic, CharacteristicPermissions, CharacteristicsTypes) from homekit.model import Accessory, get_id from homekit.exceptions import AccessoryNotFoundError -from homeassistant.components.homekit_controller import SERVICE_HOMEKIT + +from homeassistant import config_entries from homeassistant.components.homekit_controller.const import ( CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH) +from homeassistant.components.homekit_controller import ( + async_setup_entry, config_flow) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import ( - async_fire_time_changed, async_fire_service_discovered, load_fixture) +from tests.common import async_fire_time_changed, load_fixture class FakePairing: @@ -217,26 +219,36 @@ async def setup_platform(hass): return fake_controller -async def setup_test_accessories(hass, accessories, capitalize=False): - """Load a fake homekit accessory based on a homekit accessory model. - - If capitalize is True, property names will be in upper case. - """ +async def setup_test_accessories(hass, accessories): + """Load a fake homekit device based on captured JSON profile.""" fake_controller = await setup_platform(hass) pairing = fake_controller.add(accessories) discovery_info = { + 'name': 'TestDevice', 'host': '127.0.0.1', 'port': 8080, 'properties': { - ('MD' if capitalize else 'md'): 'TestDevice', - ('ID' if capitalize else 'id'): '00:00:00:00:00:00', - ('C#' if capitalize else 'c#'): 1, + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, } } - async_fire_service_discovered(hass, SERVICE_HOMEKIT, discovery_info) - await hass.async_block_till_done() + pairing.pairing_data.update({ + 'AccessoryPairingID': discovery_info['properties']['id'], + }) + + config_entry = config_entries.ConfigEntry( + 1, 'homekit_controller', 'TestData', pairing.pairing_data, + 'test', config_entries.CONN_CLASS_LOCAL_PUSH + ) + + pairing_cls_loc = 'homekit.controller.ip_implementation.IpPairing' + with mock.patch(pairing_cls_loc) as pairing_cls: + pairing_cls.return_value = pairing + await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() return pairing @@ -249,6 +261,7 @@ async def device_config_changed(hass, accessories): pairing.accessories = accessories discovery_info = { + 'name': 'TestDevice', 'host': '127.0.0.1', 'port': 8080, 'properties': { @@ -259,7 +272,14 @@ async def device_config_changed(hass, accessories): } } - async_fire_service_discovered(hass, SERVICE_HOMEKIT, discovery_info) + # Config Flow will abort and notify us if the discovery event is of + # interest - in this case c# has incremented + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + flow.context = {} + result = await flow.async_step_zeroconf(discovery_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' # Wait for services to reconfigure await hass.async_block_till_done() @@ -285,7 +305,6 @@ async def setup_test_component(hass, services, capitalize=False, suffix=None): accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') accessory.services.extend(services) - pairing = await setup_test_accessories(hass, [accessory], capitalize) - + pairing = await setup_test_accessories(hass, [accessory]) entity = 'testdevice' if suffix is None else 'testdevice_{}'.format(suffix) return Helper(hass, '.'.join((domain, entity)), pairing, accessory) 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 e0738d67083..0c77aa37196 100644 --- a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py +++ b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py @@ -39,3 +39,16 @@ async def test_aqara_gateway_setup(hass): assert light_state.attributes['supported_features'] == ( SUPPORT_BRIGHTNESS | SUPPORT_COLOR ) + + device_registry = await hass.helpers.device_registry.async_get_registry() + + # All the entities are services of the same accessory + # So it looks at the protocol like a single physical device + assert alarm.device_id == light.device_id + + device = device_registry.async_get(light.device_id) + assert device.manufacturer == 'Aqara' + assert device.name == 'Aqara Hub-1563' + assert device.model == 'ZHWA11LM' + assert device.sw_version == '1.4.7' + assert device.hub_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 23d0a32f7ad..10e01437cda 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -7,11 +7,15 @@ https://github.com/home-assistant/home-assistant/issues/15336 from unittest import mock from homekit import AccessoryDisconnectedError +import pytest +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_HUMIDITY, SUPPORT_TARGET_HUMIDITY_HIGH, SUPPORT_TARGET_HUMIDITY_LOW, SUPPORT_OPERATION_MODE) + + from tests.components.homekit_controller.common import ( FakePairing, device_config_changed, setup_accessories_from_file, setup_test_accessories, Helper @@ -63,6 +67,24 @@ async def test_ecobee3_setup(hass): occ3 = entity_registry.async_get('binary_sensor.basement') assert occ3.unique_id == 'homekit-AB3C-56' + device_registry = await hass.helpers.device_registry.async_get_registry() + + climate_device = device_registry.async_get(climate.device_id) + assert climate_device.manufacturer == 'ecobee Inc.' + 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 + + # Check that an attached sensor has its own device entity that + # is linked to the bridge + sensor_device = device_registry.async_get(occ1.device_id) + assert sensor_device.manufacturer == 'ecobee Inc.' + 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 + async def test_ecobee3_setup_from_cache(hass, hass_storage): """Test that Ecbobee can be correctly setup from its cached entity map.""" @@ -110,14 +132,19 @@ async def test_ecobee3_setup_connection_failure(hass): list_accessories = 'list_accessories_and_characteristics' with mock.patch.object(FakePairing, list_accessories) as laac: laac.side_effect = AccessoryDisconnectedError('Connection failed') - await setup_test_accessories(hass, accessories) + + # If there is no cached entity map and the accessory connection is + # failing then we have to fail the config entry setup. + with pytest.raises(ConfigEntryNotReady): + await setup_test_accessories(hass, accessories) climate = entity_registry.async_get('climate.homew') assert climate is None - # When a regular discovery event happens it should trigger another scan - # which should cause our entities to be added. - await device_config_changed(hass, accessories) + # When accessory raises ConfigEntryNoteReady HA will retry - lets make + # sure there is no cruft causing conflicts left behind by now doing + # a successful setup. + await setup_test_accessories(hass, accessories) climate = entity_registry.async_get('climate.homew') assert climate.unique_id == 'homekit-123456789012-16' 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 a741885c6d5..8de3d1587b6 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -38,6 +38,15 @@ async def test_koogeek_ls1_setup(hass): SUPPORT_BRIGHTNESS | SUPPORT_COLOR ) + device_registry = await hass.helpers.device_registry.async_get_registry() + + device = device_registry.async_get(entry.device_id) + assert device.manufacturer == 'Koogeek' + assert device.name == 'Koogeek-LS1-20833F' + assert device.model == 'LS1' + assert device.sw_version == '2.2.15' + assert device.hub_device_id is None + @pytest.mark.parametrize('failure_cls', [ AccessoryDisconnectedError, EncryptionError 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 1869161b1f8..9825e1ab4ab 100644 --- a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py +++ b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py @@ -27,3 +27,15 @@ async def test_lennox_e30_setup(hass): assert climate_state.attributes['supported_features'] == ( SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE ) + + device_registry = await hass.helpers.device_registry.async_get_registry() + + device = device_registry.async_get(climate.device_id) + assert device.manufacturer == 'Lennox' + assert device.name == 'Lennox' + assert device.model == 'E30 2B' + assert device.sw_version == '3.40.XX' + + # The fixture contains a single accessory - so its a single device + # and no bridge + assert device.hub_device_id is None diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index c8b81a88478..b5f923dd55e 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -13,14 +13,25 @@ from tests.components.homekit_controller.common import ( ) -ERROR_MAPPING_FORM_FIXTURE = [ - (homekit.MaxPeersError, 'max_peers_error'), +PAIRING_START_FORM_ERRORS = [ (homekit.BusyError, 'busy_error'), (homekit.MaxTriesError, 'max_tries_error'), (KeyError, 'pairing_failed'), ] -ERROR_MAPPING_ABORT_FIXTURE = [ +PAIRING_START_ABORT_ERRORS = [ + (homekit.AccessoryNotFoundError, 'accessory_not_found_error'), + (homekit.UnavailableError, 'already_paired'), +] + +PAIRING_FINISH_FORM_ERRORS = [ + (homekit.MaxPeersError, 'max_peers_error'), + (homekit.AuthenticationError, 'authentication_error'), + (homekit.UnknownError, 'unknown_error'), + (KeyError, 'pairing_failed'), +] + +PAIRING_FINISH_ABORT_ERRORS = [ (homekit.AccessoryNotFoundError, 'accessory_not_found_error'), ] @@ -29,9 +40,22 @@ def _setup_flow_handler(hass): flow = config_flow.HomekitControllerFlowHandler() flow.hass = hass flow.context = {} + + flow.controller = mock.Mock() + flow.controller.pairings = {} + return flow +async def _setup_flow_zeroconf(hass, discovery_info): + result = await hass.config_entries.flow.async_init( + 'homekit_controller', + context={'source': 'zeroconf'}, + data=discovery_info, + ) + return result + + async def test_discovery_works(hass): """Test a device being discovered.""" discovery_info = { @@ -48,10 +72,20 @@ async def test_discovery_works(hass): flow = _setup_flow_handler(hass) - result = await flow.async_step_discovery(discovery_info) + # Device is discovered + result = await flow.async_step_zeroconf(discovery_info) assert result['type'] == 'form' assert result['step_id'] == 'pair' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } + + # User initiates pairing - device enters pairing mode and displays code + result = await flow.async_step_pair({}) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + assert flow.controller.start_pairing.call_count == 1 pairing = mock.Mock(pairing_data={ 'AccessoryPairingID': '00:00:00:00:00:00', @@ -68,17 +102,13 @@ async def test_discovery_works(hass): }] }] - controller = mock.Mock() - controller.pairings = { + # Pairing doesn't error error and pairing results + flow.controller.pairings = { '00:00:00:00:00:00': pairing, } - - with mock.patch('homekit.Controller') as controller_cls: - controller_cls.return_value = controller - result = await flow.async_step_pair({ - 'pairing_code': '111-22-33', - }) - + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) assert result['type'] == 'create_entry' assert result['title'] == 'Koogeek-LS1-20833F' assert result['data'] == pairing.pairing_data @@ -100,10 +130,20 @@ async def test_discovery_works_upper_case(hass): flow = _setup_flow_handler(hass) - result = await flow.async_step_discovery(discovery_info) + # Device is discovered + result = await flow.async_step_zeroconf(discovery_info) assert result['type'] == 'form' assert result['step_id'] == 'pair' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } + + # User initiates pairing - device enters pairing mode and displays code + result = await flow.async_step_pair({}) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + assert flow.controller.start_pairing.call_count == 1 pairing = mock.Mock(pairing_data={ 'AccessoryPairingID': '00:00:00:00:00:00', @@ -120,17 +160,12 @@ async def test_discovery_works_upper_case(hass): }] }] - controller = mock.Mock() - controller.pairings = { + flow.controller.pairings = { '00:00:00:00:00:00': pairing, } - - with mock.patch('homekit.Controller') as controller_cls: - controller_cls.return_value = controller - result = await flow.async_step_pair({ - 'pairing_code': '111-22-33', - }) - + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) assert result['type'] == 'create_entry' assert result['title'] == 'Koogeek-LS1-20833F' assert result['data'] == pairing.pairing_data @@ -151,10 +186,20 @@ async def test_discovery_works_missing_csharp(hass): flow = _setup_flow_handler(hass) - result = await flow.async_step_discovery(discovery_info) + # Device is discovered + result = await flow.async_step_zeroconf(discovery_info) assert result['type'] == 'form' assert result['step_id'] == 'pair' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } + + # User initiates pairing - device enters pairing mode and displays code + result = await flow.async_step_pair({}) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + assert flow.controller.start_pairing.call_count == 1 pairing = mock.Mock(pairing_data={ 'AccessoryPairingID': '00:00:00:00:00:00', @@ -171,22 +216,41 @@ async def test_discovery_works_missing_csharp(hass): }] }] - controller = mock.Mock() - controller.pairings = { + flow.controller.pairings = { '00:00:00:00:00:00': pairing, } - with mock.patch('homekit.Controller') as controller_cls: - controller_cls.return_value = controller - result = await flow.async_step_pair({ - 'pairing_code': '111-22-33', - }) - + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) assert result['type'] == 'create_entry' assert result['title'] == 'Koogeek-LS1-20833F' assert result['data'] == pairing.pairing_data +async def test_abort_duplicate_flow(hass): + """Already paired.""" + discovery_info = { + 'name': 'TestDevice', + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + result = await _setup_flow_zeroconf(hass, discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + result = await _setup_flow_zeroconf(hass, discovery_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_in_progress' + + async def test_pair_already_paired_1(hass): """Already paired.""" discovery_info = { @@ -203,10 +267,13 @@ async def test_pair_already_paired_1(hass): flow = _setup_flow_handler(hass) - result = await flow.async_step_discovery(discovery_info) + result = await flow.async_step_zeroconf(discovery_info) assert result['type'] == 'abort' assert result['reason'] == 'already_paired' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } async def test_discovery_ignored_model(hass): @@ -225,10 +292,13 @@ async def test_discovery_ignored_model(hass): flow = _setup_flow_handler(hass) - result = await flow.async_step_discovery(discovery_info) + result = await flow.async_step_zeroconf(discovery_info) assert result['type'] == 'abort' assert result['reason'] == 'ignored_model' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } async def test_discovery_invalid_config_entry(hass): @@ -254,10 +324,13 @@ async def test_discovery_invalid_config_entry(hass): flow = _setup_flow_handler(hass) - result = await flow.async_step_discovery(discovery_info) + result = await flow.async_step_zeroconf(discovery_info) assert result['type'] == 'form' assert result['step_id'] == 'pair' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } # Discovery of a HKID that is in a pairable state but for which there is # already a config entry - in that case the stale config entry is @@ -288,10 +361,13 @@ async def test_discovery_already_configured(hass): flow = _setup_flow_handler(hass) - result = await flow.async_step_discovery(discovery_info) + result = await flow.async_step_zeroconf(discovery_info) assert result['type'] == 'abort' assert result['reason'] == 'already_configured' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } assert conn.async_config_num_changed.call_count == 0 @@ -318,10 +394,13 @@ async def test_discovery_already_configured_config_change(hass): flow = _setup_flow_handler(hass) - result = await flow.async_step_discovery(discovery_info) + result = await flow.async_step_zeroconf(discovery_info) assert result['type'] == 'abort' assert result['reason'] == 'already_configured' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } assert conn.async_refresh_entity_map.call_args == mock.call(2) @@ -342,26 +421,31 @@ async def test_pair_unable_to_pair(hass): flow = _setup_flow_handler(hass) - result = await flow.async_step_discovery(discovery_info) + # Device is discovered + result = await flow.async_step_zeroconf(discovery_info) assert result['type'] == 'form' assert result['step_id'] == 'pair' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } - controller = mock.Mock() - controller.pairings = {} - - with mock.patch('homekit.Controller') as controller_cls: - controller_cls.return_value = controller - result = await flow.async_step_pair({ - 'pairing_code': '111-22-33', - }) + # User initiates pairing - device enters pairing mode and displays code + result = await flow.async_step_pair({}) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + assert flow.controller.start_pairing.call_count == 1 + # Pairing doesn't error but no pairing object is generated + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) assert result['type'] == 'form' assert result['errors']['pairing_code'] == 'unable_to_pair' -@pytest.mark.parametrize("exception,expected", ERROR_MAPPING_ABORT_FIXTURE) -async def test_pair_abort_errors(hass, exception, expected): +@pytest.mark.parametrize("exception,expected", PAIRING_START_ABORT_ERRORS) +async def test_pair_abort_errors_on_start(hass, exception, expected): """Test various pairing errors.""" discovery_info = { 'name': 'TestDevice', @@ -377,28 +461,30 @@ async def test_pair_abort_errors(hass, exception, expected): flow = _setup_flow_handler(hass) - result = await flow.async_step_discovery(discovery_info) + # Device is discovered + result = await flow.async_step_zeroconf(discovery_info) assert result['type'] == 'form' assert result['step_id'] == 'pair' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } - controller = mock.Mock() - controller.pairings = {} - - with mock.patch('homekit.Controller') as controller_cls: - controller_cls.return_value = controller - controller.perform_pairing.side_effect = exception('error') - result = await flow.async_step_pair({ - 'pairing_code': '111-22-33', - }) + # User initiates pairing - device refuses to enter pairing mode + with mock.patch.object(flow.controller, 'start_pairing') as start_pairing: + start_pairing.side_effect = exception('error') + result = await flow.async_step_pair({}) assert result['type'] == 'abort' assert result['reason'] == expected - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } -@pytest.mark.parametrize("exception,expected", ERROR_MAPPING_FORM_FIXTURE) -async def test_pair_form_errors(hass, exception, expected): +@pytest.mark.parametrize("exception,expected", PAIRING_START_FORM_ERRORS) +async def test_pair_form_errors_on_start(hass, exception, expected): """Test various pairing errors.""" discovery_info = { 'name': 'TestDevice', @@ -414,28 +500,31 @@ async def test_pair_form_errors(hass, exception, expected): flow = _setup_flow_handler(hass) - result = await flow.async_step_discovery(discovery_info) + # Device is discovered + result = await flow.async_step_zeroconf(discovery_info) assert result['type'] == 'form' assert result['step_id'] == 'pair' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } - controller = mock.Mock() - controller.pairings = {} - - with mock.patch('homekit.Controller') as controller_cls: - controller_cls.return_value = controller - controller.perform_pairing.side_effect = exception('error') - result = await flow.async_step_pair({ - 'pairing_code': '111-22-33', - }) + # User initiates pairing - device refuses to enter pairing mode + with mock.patch.object(flow.controller, 'start_pairing') as start_pairing: + start_pairing.side_effect = exception('error') + result = await flow.async_step_pair({}) assert result['type'] == 'form' assert result['errors']['pairing_code'] == expected - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } -async def test_pair_authentication_error(hass): - """Pairing code is incorrect.""" +@pytest.mark.parametrize("exception,expected", PAIRING_FINISH_ABORT_ERRORS) +async def test_pair_abort_errors_on_finish(hass, exception, expected): + """Test various pairing errors.""" discovery_info = { 'name': 'TestDevice', 'host': '127.0.0.1', @@ -450,96 +539,77 @@ async def test_pair_authentication_error(hass): flow = _setup_flow_handler(hass) - result = await flow.async_step_discovery(discovery_info) + # Device is discovered + result = await flow.async_step_zeroconf(discovery_info) assert result['type'] == 'form' assert result['step_id'] == 'pair' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} - - controller = mock.Mock() - controller.pairings = {} - - with mock.patch('homekit.Controller') as controller_cls: - controller_cls.return_value = controller - exc = homekit.AuthenticationError('Invalid pairing code') - controller.perform_pairing.side_effect = exc - result = await flow.async_step_pair({ - 'pairing_code': '111-22-33', - }) - - assert result['type'] == 'form' - assert result['errors']['pairing_code'] == 'authentication_error' - - -async def test_pair_unknown_error(hass): - """Pairing failed for an unknown rason.""" - discovery_info = { - 'name': 'TestDevice', - 'host': '127.0.0.1', - 'port': 8080, - 'properties': { - 'md': 'TestDevice', - 'id': '00:00:00:00:00:00', - 'c#': 1, - 'sf': 1, - } + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} } - flow = _setup_flow_handler(hass) - - result = await flow.async_step_discovery(discovery_info) + # User initiates pairing - device enters pairing mode and displays code + result = await flow.async_step_pair({}) assert result['type'] == 'form' assert result['step_id'] == 'pair' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} - - controller = mock.Mock() - controller.pairings = {} - - with mock.patch('homekit.Controller') as controller_cls: - controller_cls.return_value = controller - exc = homekit.UnknownError('Unknown error') - controller.perform_pairing.side_effect = exc - result = await flow.async_step_pair({ - 'pairing_code': '111-22-33', - }) - - assert result['type'] == 'form' - assert result['errors']['pairing_code'] == 'unknown_error' - - -async def test_pair_already_paired(hass): - """Device is already paired.""" - discovery_info = { - 'name': 'TestDevice', - 'host': '127.0.0.1', - 'port': 8080, - 'properties': { - 'md': 'TestDevice', - 'id': '00:00:00:00:00:00', - 'c#': 1, - 'sf': 1, - } - } - - flow = _setup_flow_handler(hass) - - result = await flow.async_step_discovery(discovery_info) - assert result['type'] == 'form' - assert result['step_id'] == 'pair' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} - - controller = mock.Mock() - controller.pairings = {} - - with mock.patch('homekit.Controller') as controller_cls: - controller_cls.return_value = controller - exc = homekit.UnavailableError('Unavailable error') - controller.perform_pairing.side_effect = exc - result = await flow.async_step_pair({ - 'pairing_code': '111-22-33', - }) + assert flow.controller.start_pairing.call_count == 1 + # User submits code - pairing fails but can be retried + flow.finish_pairing.side_effect = exception('error') + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) assert result['type'] == 'abort' - assert result['reason'] == 'already_paired' + assert result['reason'] == expected + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } + + +@pytest.mark.parametrize("exception,expected", PAIRING_FINISH_FORM_ERRORS) +async def test_pair_form_errors_on_finish(hass, exception, expected): + """Test various pairing errors.""" + discovery_info = { + 'name': 'TestDevice', + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = _setup_flow_handler(hass) + + # Device is discovered + result = await flow.async_step_zeroconf(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } + + # User initiates pairing - device enters pairing mode and displays code + result = await flow.async_step_pair({}) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + assert flow.controller.start_pairing.call_count == 1 + + # User submits code - pairing fails but can be retried + flow.finish_pairing.side_effect = exception('error') + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + assert result['type'] == 'form' + assert result['errors']['pairing_code'] == expected + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } async def test_import_works(hass): @@ -627,12 +697,10 @@ async def test_user_works(hass): 'name': 'TestDevice', 'host': '127.0.0.1', 'port': 8080, - 'properties': { - 'md': 'TestDevice', - 'id': '00:00:00:00:00:00', - 'c#': 1, - 'sf': 1, - } + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, } pairing = mock.Mock(pairing_data={ @@ -649,33 +717,28 @@ async def test_user_works(hass): }] }] - controller = mock.Mock() - controller.pairings = { + flow = _setup_flow_handler(hass) + + flow.controller.pairings = { '00:00:00:00:00:00': pairing, } - controller.discover.return_value = [ + flow.controller.discover.return_value = [ discovery_info, ] - flow = _setup_flow_handler(hass) - - with mock.patch('homekit.Controller') as controller_cls: - controller_cls.return_value = controller - result = await flow.async_step_user() + result = await flow.async_step_user() assert result['type'] == 'form' assert result['step_id'] == 'user' result = await flow.async_step_user({ - 'device': '00:00:00:00:00:00', + 'device': 'TestDevice', }) assert result['type'] == 'form' assert result['step_id'] == 'pair' - with mock.patch('homekit.Controller') as controller_cls: - controller_cls.return_value = controller - result = await flow.async_step_pair({ - 'pairing_code': '111-22-33', - }) + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) assert result['type'] == 'create_entry' assert result['title'] == 'Koogeek-LS1-20833F' assert result['data'] == pairing.pairing_data @@ -685,9 +748,8 @@ async def test_user_no_devices(hass): """Test user initiated pairing where no devices discovered.""" flow = _setup_flow_handler(hass) - with mock.patch('homekit.Controller') as controller_cls: - controller_cls.return_value.discover.return_value = [] - result = await flow.async_step_user() + flow.controller.discover.return_value = [] + result = await flow.async_step_user() assert result['type'] == 'abort' assert result['reason'] == 'no_devices' @@ -701,19 +763,16 @@ async def test_user_no_unpaired_devices(hass): 'name': 'TestDevice', 'host': '127.0.0.1', 'port': 8080, - 'properties': { - 'md': 'TestDevice', - 'id': '00:00:00:00:00:00', - 'c#': 1, - 'sf': 0, - } + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, } - with mock.patch('homekit.Controller') as controller_cls: - controller_cls.return_value.discover.return_value = [ - discovery_info, - ] - result = await flow.async_step_user() + flow.controller.discover.return_value = [ + discovery_info, + ] + result = await flow.async_step_user() assert result['type'] == 'abort' assert result['reason'] == 'no_devices' @@ -762,12 +821,15 @@ async def test_parse_new_homekit_json(hass): pairing_cls.return_value = pairing with mock.patch('builtins.open', mock_open): with mock.patch('os.path', mock_path): - result = await flow.async_step_discovery(discovery_info) + result = await flow.async_step_zeroconf(discovery_info) assert result['type'] == 'create_entry' assert result['title'] == 'TestDevice' assert result['data']['AccessoryPairingID'] == '00:00:00:00:00:00' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } async def test_parse_old_homekit_json(hass): @@ -820,12 +882,15 @@ async def test_parse_old_homekit_json(hass): with mock.patch('builtins.open', mock_open): with mock.patch('os.path', mock_path): with mock.patch('os.listdir', mock_listdir): - result = await flow.async_step_discovery(discovery_info) + result = await flow.async_step_zeroconf(discovery_info) assert result['type'] == 'create_entry' assert result['title'] == 'TestDevice' assert result['data']['AccessoryPairingID'] == '00:00:00:00:00:00' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } async def test_parse_overlapping_homekit_json(hass): @@ -889,11 +954,14 @@ async def test_parse_overlapping_homekit_json(hass): with mock.patch('builtins.open', side_effect=side_effects): with mock.patch('os.path', mock_path): with mock.patch('os.listdir', mock_listdir): - result = await flow.async_step_discovery(discovery_info) + result = await flow.async_step_zeroconf(discovery_info) await hass.async_block_till_done() assert result['type'] == 'create_entry' assert result['title'] == 'TestDevice' assert result['data']['AccessoryPairingID'] == '00:00:00:00:00:00' - assert flow.context == {'title_placeholders': {'name': 'TestDevice'}} + assert flow.context == { + 'hkid': '00:00:00:00:00:00', + 'title_placeholders': {'name': 'TestDevice'} + } diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py index 19ccc21b7e8..66d4505d6fb 100644 --- a/tests/components/homekit_controller/test_cover.py +++ b/tests/components/homekit_controller/test_cover.py @@ -71,20 +71,6 @@ def create_window_covering_service_with_v_tilt(): return service -async def test_accept_capitalized_property_names(hass, utcnow): - """Test that we can handle a device with capitalized property names.""" - window_cover = create_window_covering_service() - helper = await setup_test_component(hass, [window_cover], capitalize=True) - - # The specific interaction we do here doesn't matter; we just need - # to do *something* to ensure that discovery properly dealt with the - # capitalized property names. - await hass.services.async_call('cover', 'open_cover', { - 'entity_id': helper.entity_id, - }, blocking=True) - assert helper.characteristics[POSITION_TARGET].value == 100 - - async def test_change_window_cover_state(hass, utcnow): """Test that we can turn a HomeKit alarm on and off again.""" window_cover = create_window_covering_service() diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index e17fb105efe..d9fa6c11309 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -140,3 +140,15 @@ async def test_cors_middleware_with_cors_allowed_view(hass): hass.http.app._on_startup.freeze() await hass.http.app.startup() + + +async def test_cors_works_with_frontend(hass, hass_client): + """Test CORS works with the frontend.""" + assert await async_setup_component(hass, 'frontend', { + 'http': { + 'cors_allowed_origins': ['http://home-assistant.io'] + } + }) + client = await hass_client() + resp = await client.get('/') + assert resp.status == 200 diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index fe3bffe5357..b7736e62390 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -130,7 +130,7 @@ async def test_flow_timeout_discovery(hass): flow = config_flow.HueFlowHandler() flow.hass = hass - with patch('aiohue.discovery.discover_nupnp', + with patch('homeassistant.components.hue.config_flow.discover_nupnp', side_effect=asyncio.TimeoutError): result = await flow.async_step_init() @@ -185,37 +185,53 @@ async def test_flow_link_unknown_host(hass): } -async def test_bridge_discovery(hass): +async def test_bridge_ssdp(hass): """Test a bridge being discovered.""" flow = config_flow.HueFlowHandler() flow.hass = hass + flow.context = {} with patch.object(config_flow, 'get_bridge', side_effect=errors.AuthenticationRequired): - result = await flow.async_step_discovery({ + result = await flow.async_step_ssdp({ 'host': '0.0.0.0', - 'serial': '1234' + 'serial': '1234', + 'manufacturerURL': config_flow.HUE_MANUFACTURERURL }) assert result['type'] == 'form' assert result['step_id'] == 'link' -async def test_bridge_discovery_emulated_hue(hass): - """Test if discovery info is from an emulated hue instance.""" +async def test_bridge_ssdp_discover_other_bridge(hass): + """Test that discovery ignores other bridges.""" flow = config_flow.HueFlowHandler() flow.hass = hass - result = await flow.async_step_discovery({ - 'name': 'HASS Bridge', - 'host': '0.0.0.0', - 'serial': '1234' + result = await flow.async_step_ssdp({ + 'manufacturerURL': 'http://www.notphilips.com' }) assert result['type'] == 'abort' -async def test_bridge_discovery_already_configured(hass): +async def test_bridge_ssdp_emulated_hue(hass): + """Test if discovery info is from an emulated hue instance.""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + flow.context = {} + + result = await flow.async_step_ssdp({ + 'name': 'HASS Bridge', + 'host': '0.0.0.0', + 'serial': '1234', + 'manufacturerURL': config_flow.HUE_MANUFACTURERURL + }) + + assert result['type'] == 'abort' + + +async def test_bridge_ssdp_already_configured(hass): """Test if a discovered bridge has already been configured.""" MockConfigEntry(domain='hue', data={ 'host': '0.0.0.0' @@ -223,10 +239,12 @@ async def test_bridge_discovery_already_configured(hass): flow = config_flow.HueFlowHandler() flow.hass = hass + flow.context = {} - result = await flow.async_step_discovery({ + result = await flow.async_step_ssdp({ 'host': '0.0.0.0', - 'serial': '1234' + 'serial': '1234', + 'manufacturerURL': config_flow.HUE_MANUFACTURERURL }) assert result['type'] == 'abort' diff --git a/tests/components/iqvia/__init__.py b/tests/components/iqvia/__init__.py new file mode 100644 index 00000000000..a4a57b8aafa --- /dev/null +++ b/tests/components/iqvia/__init__.py @@ -0,0 +1 @@ +"""Define tests for IQVIA.""" diff --git a/tests/components/iqvia/test_config_flow.py b/tests/components/iqvia/test_config_flow.py new file mode 100644 index 00000000000..48edc36629e --- /dev/null +++ b/tests/components/iqvia/test_config_flow.py @@ -0,0 +1,86 @@ +"""Define tests for the IQVIA config flow.""" +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.iqvia import CONF_ZIP_CODE, DOMAIN, config_flow + +from tests.common import MockConfigEntry, MockDependency + + +@pytest.fixture +def mock_pyiqvia(): + """Mock the pyiqvia library.""" + with MockDependency('pyiqvia') as mock_pyiqvia_: + yield mock_pyiqvia_ + + +async def test_duplicate_error(hass): + """Test that errors are shown when duplicates are added.""" + conf = { + CONF_ZIP_CODE: '12345', + } + + MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) + flow = config_flow.IQVIAFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_ZIP_CODE: 'identifier_exists'} + + +async def test_invalid_zip_code(hass, mock_pyiqvia): + """Test that an invalid ZIP code key throws an error.""" + conf = { + CONF_ZIP_CODE: 'abcde', + } + + flow = config_flow.IQVIAFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_ZIP_CODE: 'invalid_zip_code'} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + flow = config_flow.IQVIAFlowHandler() + 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_step_import(hass, mock_pyiqvia): + """Test that the import step works.""" + conf = { + CONF_ZIP_CODE: '12345', + } + + flow = config_flow.IQVIAFlowHandler() + flow.hass = hass + + result = await flow.async_step_import(import_config=conf) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '12345' + assert result['data'] == { + CONF_ZIP_CODE: '12345', + } + + +async def test_step_user(hass, mock_pyiqvia): + """Test that the user step works.""" + conf = { + CONF_ZIP_CODE: '12345', + } + + flow = config_flow.IQVIAFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '12345' + assert result['data'] == { + CONF_ZIP_CODE: '12345', + } diff --git a/tests/components/locative/test_init.py b/tests/components/locative/test_init.py index 6d541cac653..81248764971 100644 --- a/tests/components/locative/test_init.py +++ b/tests/components/locative/test_init.py @@ -30,7 +30,7 @@ async def locative_client(loop, hass, hass_client): }) await hass.async_block_till_done() - with patch('homeassistant.components.device_tracker.update_config'): + with patch('homeassistant.components.device_tracker.legacy.update_config'): return await hass_client() diff --git a/tests/components/mobile_app/__init__.py b/tests/components/mobile_app/__init__.py index 98c7a20b059..9b37214d079 100644 --- a/tests/components/mobile_app/__init__.py +++ b/tests/components/mobile_app/__init__.py @@ -1,74 +1 @@ -"""Tests for mobile_app component.""" -# pylint: disable=redefined-outer-name,unused-import -import pytest - -from tests.common import mock_device_registry - -from homeassistant.setup import async_setup_component - -from homeassistant.components.mobile_app.const import (DATA_BINARY_SENSOR, - DATA_DELETED_IDS, - DATA_SENSOR, - DOMAIN, - STORAGE_KEY, - STORAGE_VERSION) - -from .const import REGISTER, REGISTER_CLEARTEXT - - -@pytest.fixture -def registry(hass): - """Return a configured device registry.""" - return mock_device_registry(hass) - - -@pytest.fixture -async def create_registrations(authed_api_client): - """Return two new registrations.""" - enc_reg = await authed_api_client.post( - '/api/mobile_app/registrations', json=REGISTER - ) - - assert enc_reg.status == 201 - enc_reg_json = await enc_reg.json() - - clear_reg = await authed_api_client.post( - '/api/mobile_app/registrations', json=REGISTER_CLEARTEXT - ) - - assert clear_reg.status == 201 - clear_reg_json = await clear_reg.json() - - return (enc_reg_json, clear_reg_json) - - -@pytest.fixture -async def webhook_client(hass, aiohttp_client, hass_storage, hass_admin_user): - """mobile_app mock client.""" - hass_storage[STORAGE_KEY] = { - 'version': STORAGE_VERSION, - 'data': { - DATA_BINARY_SENSOR: {}, - DATA_DELETED_IDS: [], - DATA_SENSOR: {} - } - } - - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() - return await aiohttp_client(hass.http.app) - - -@pytest.fixture -async def authed_api_client(hass, hass_client): - """Provide an authenticated client for mobile_app to use.""" - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() - return await hass_client() - - -@pytest.fixture(autouse=True) -async def setup_ws(hass): - """Configure the websocket_api component.""" - assert await async_setup_component(hass, 'websocket_api', {}) - await hass.async_block_till_done() +"""Tests for the mobile app integration.""" diff --git a/tests/components/mobile_app/conftest.py b/tests/components/mobile_app/conftest.py new file mode 100644 index 00000000000..b20d164e6e6 --- /dev/null +++ b/tests/components/mobile_app/conftest.py @@ -0,0 +1,60 @@ +"""Tests for mobile_app component.""" +# pylint: disable=redefined-outer-name,unused-import +import pytest + +from tests.common import mock_device_registry + +from homeassistant.setup import async_setup_component + +from homeassistant.components.mobile_app.const import DOMAIN + +from .const import REGISTER, REGISTER_CLEARTEXT + + +@pytest.fixture +def registry(hass): + """Return a configured device registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +async def create_registrations(authed_api_client): + """Return two new registrations.""" + enc_reg = await authed_api_client.post( + '/api/mobile_app/registrations', json=REGISTER + ) + + assert enc_reg.status == 201 + enc_reg_json = await enc_reg.json() + + clear_reg = await authed_api_client.post( + '/api/mobile_app/registrations', json=REGISTER_CLEARTEXT + ) + + assert clear_reg.status == 201 + clear_reg_json = await clear_reg.json() + + return (enc_reg_json, clear_reg_json) + + +@pytest.fixture +async def webhook_client(hass, aiohttp_client): + """mobile_app mock client.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + return await aiohttp_client(hass.http.app) + + +@pytest.fixture +async def authed_api_client(hass, hass_client): + """Provide an authenticated client for mobile_app to use.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + return await hass_client() + + +@pytest.fixture(autouse=True) +async def setup_ws(hass): + """Configure the websocket_api component.""" + assert await async_setup_component(hass, 'websocket_api', {}) + await hass.async_block_till_done() diff --git a/tests/components/mobile_app/test_device_tracker.py b/tests/components/mobile_app/test_device_tracker.py new file mode 100644 index 00000000000..53f9ad6f6dd --- /dev/null +++ b/tests/components/mobile_app/test_device_tracker.py @@ -0,0 +1,116 @@ +"""Test mobile app device tracker.""" + + +async def test_sending_location(hass, create_registrations, webhook_client): + """Test sending a location via a webhook.""" + resp = await webhook_client.post( + '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), + json={ + 'type': 'update_location', + 'data': { + 'gps': [10, 20], + 'gps_accuracy': 30, + 'battery': 40, + 'altitude': 50, + 'course': 60, + 'speed': 70, + 'vertical_accuracy': 80, + 'location_name': 'bar', + } + } + ) + + assert resp.status == 200 + await hass.async_block_till_done() + state = hass.states.get('device_tracker.test_1_2') + assert state is not None + assert state.name == 'Test 1' + assert state.state == 'bar' + assert state.attributes['source_type'] == 'gps' + assert state.attributes['latitude'] == 10 + assert state.attributes['longitude'] == 20 + assert state.attributes['gps_accuracy'] == 30 + assert state.attributes['battery_level'] == 40 + assert state.attributes['altitude'] == 50 + assert state.attributes['course'] == 60 + assert state.attributes['speed'] == 70 + assert state.attributes['vertical_accuracy'] == 80 + + resp = await webhook_client.post( + '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), + json={ + 'type': 'update_location', + 'data': { + 'gps': [1, 2], + 'gps_accuracy': 3, + 'battery': 4, + 'altitude': 5, + 'course': 6, + 'speed': 7, + 'vertical_accuracy': 8, + } + } + ) + + assert resp.status == 200 + await hass.async_block_till_done() + state = hass.states.get('device_tracker.test_1_2') + assert state is not None + assert state.state == 'not_home' + assert state.attributes['source_type'] == 'gps' + assert state.attributes['latitude'] == 1 + assert state.attributes['longitude'] == 2 + assert state.attributes['gps_accuracy'] == 3 + assert state.attributes['battery_level'] == 4 + assert state.attributes['altitude'] == 5 + assert state.attributes['course'] == 6 + assert state.attributes['speed'] == 7 + assert state.attributes['vertical_accuracy'] == 8 + + +async def test_restoring_location(hass, create_registrations, webhook_client): + """Test sending a location via a webhook.""" + resp = await webhook_client.post( + '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), + json={ + 'type': 'update_location', + 'data': { + 'gps': [10, 20], + 'gps_accuracy': 30, + 'battery': 40, + 'altitude': 50, + 'course': 60, + 'speed': 70, + 'vertical_accuracy': 80, + 'location_name': 'bar', + } + } + ) + + assert resp.status == 200 + await hass.async_block_till_done() + state_1 = hass.states.get('device_tracker.test_1_2') + assert state_1 is not None + + config_entry = hass.config_entries.async_entries('mobile_app')[1] + + # mobile app doesn't support unloading, so we just reload device tracker + await hass.config_entries.async_forward_entry_unload(config_entry, + 'device_tracker') + await hass.config_entries.async_forward_entry_setup(config_entry, + 'device_tracker') + + state_2 = hass.states.get('device_tracker.test_1_2') + assert state_2 is not None + + assert state_1 is not state_2 + assert state_2.name == 'Test 1' + assert state_2.attributes['source_type'] == 'gps' + assert state_2.attributes['latitude'] == 10 + assert state_2.attributes['longitude'] == 20 + assert state_2.attributes['gps_accuracy'] == 30 + assert state_2.attributes['battery_level'] == 40 + assert state_2.attributes['altitude'] == 50 + assert state_2.attributes['course'] == 60 + assert state_2.attributes['speed'] == 70 + assert state_2.attributes['vertical_accuracy'] == 80 diff --git a/tests/components/mobile_app/test_entity.py b/tests/components/mobile_app/test_entity.py index e98307468d1..750c346cbc3 100644 --- a/tests/components/mobile_app/test_entity.py +++ b/tests/components/mobile_app/test_entity.py @@ -2,9 +2,6 @@ # pylint: disable=redefined-outer-name,unused-import import logging -from . import (authed_api_client, create_registrations, # noqa: F401 - webhook_client) # noqa: F401 - _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/mobile_app/test_http_api.py b/tests/components/mobile_app/test_http_api.py index dc51b850a16..80f01315f70 100644 --- a/tests/components/mobile_app/test_http_api.py +++ b/tests/components/mobile_app/test_http_api.py @@ -7,10 +7,9 @@ from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.setup import async_setup_component from .const import REGISTER, RENDER_TEMPLATE -from . import authed_api_client # noqa: F401 -async def test_registration(hass, hass_client): # noqa: F811 +async def test_registration(hass, hass_client): """Test that registrations happen.""" try: # pylint: disable=unused-import diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 43eac28ec18..cd5b0a5bbed 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -11,17 +11,14 @@ from homeassistant.setup import async_setup_component from tests.common import async_mock_service -from . import (authed_api_client, create_registrations, # noqa: F401 - webhook_client) # noqa: F401 - from .const import (CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT, RENDER_TEMPLATE, UPDATE) _LOGGER = logging.getLogger(__name__) -async def test_webhook_handle_render_template(create_registrations, # noqa: F401, F811, E501 - webhook_client): # noqa: F811 +async def test_webhook_handle_render_template(create_registrations, + webhook_client): """Test that we render templates properly.""" resp = await webhook_client.post( '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), @@ -34,7 +31,7 @@ async def test_webhook_handle_render_template(create_registrations, # noqa: F40 assert json == {'one': 'Hello world'} -async def test_webhook_handle_call_services(hass, create_registrations, # noqa: F401, F811, E501 +async def test_webhook_handle_call_services(hass, create_registrations, webhook_client): # noqa: E501 F811 """Test that we call services properly.""" calls = async_mock_service(hass, 'test', 'mobile_app') @@ -49,8 +46,8 @@ async def test_webhook_handle_call_services(hass, create_registrations, # noqa: assert len(calls) == 1 -async def test_webhook_handle_fire_event(hass, create_registrations, # noqa: F401, F811, E501 - webhook_client): # noqa: F811 +async def test_webhook_handle_fire_event(hass, create_registrations, + webhook_client): """Test that we can fire events.""" events = [] @@ -76,7 +73,7 @@ async def test_webhook_handle_fire_event(hass, create_registrations, # noqa: F4 async def test_webhook_update_registration(webhook_client, hass_client): # noqa: E501 F811 """Test that a we can update an existing registration via webhook.""" - authed_api_client = await hass_client() # noqa: F811 + authed_api_client = await hass_client() register_resp = await authed_api_client.post( '/api/mobile_app/registrations', json=REGISTER_CLEARTEXT ) @@ -102,8 +99,8 @@ async def test_webhook_update_registration(webhook_client, hass_client): # noqa assert CONF_SECRET not in update_json -async def test_webhook_handle_get_zones(hass, create_registrations, # noqa: F401, F811, E501 - webhook_client): # noqa: F811 +async def test_webhook_handle_get_zones(hass, create_registrations, + webhook_client): """Test that we can get zones properly.""" await async_setup_component(hass, ZONE_DOMAIN, { ZONE_DOMAIN: { @@ -126,8 +123,8 @@ async def test_webhook_handle_get_zones(hass, create_registrations, # noqa: F40 assert json[0]['entity_id'] == 'zone.home' -async def test_webhook_handle_get_config(hass, create_registrations, # noqa: F401, F811, E501 - webhook_client): # noqa: F811 +async def test_webhook_handle_get_config(hass, create_registrations, + webhook_client): """Test that we can get config properly.""" resp = await webhook_client.post( '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), @@ -160,8 +157,8 @@ async def test_webhook_handle_get_config(hass, create_registrations, # noqa: F4 assert expected_dict == json -async def test_webhook_returns_error_incorrect_json(webhook_client, # noqa: F401, F811, E501 - create_registrations, # noqa: F401, F811, E501 +async def test_webhook_returns_error_incorrect_json(webhook_client, + create_registrations, caplog): # noqa: E501 F811 """Test that an error is returned when JSON is invalid.""" resp = await webhook_client.post( @@ -175,8 +172,8 @@ async def test_webhook_returns_error_incorrect_json(webhook_client, # noqa: F40 assert 'invalid JSON' in caplog.text -async def test_webhook_handle_decryption(webhook_client, # noqa: F811 - create_registrations): # noqa: F401, F811, E501 +async def test_webhook_handle_decryption(webhook_client, + create_registrations): """Test that we can encrypt/decrypt properly.""" try: # pylint: disable=unused-import @@ -221,8 +218,8 @@ async def test_webhook_handle_decryption(webhook_client, # noqa: F811 assert json.loads(decrypted_data) == {'one': 'Hello world'} -async def test_webhook_requires_encryption(webhook_client, # noqa: F811 - create_registrations): # noqa: F401, F811, E501 +async def test_webhook_requires_encryption(webhook_client, + create_registrations): """Test that encrypted registrations only accept encrypted data.""" resp = await webhook_client.post( '/api/webhook/{}'.format(create_registrations[0]['webhook_id']), diff --git a/tests/components/mobile_app/test_websocket_api.py b/tests/components/mobile_app/test_websocket_api.py index ee656159d2e..20676731393 100644 --- a/tests/components/mobile_app/test_websocket_api.py +++ b/tests/components/mobile_app/test_websocket_api.py @@ -5,7 +5,6 @@ from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.setup import async_setup_component -from . import authed_api_client, setup_ws, webhook_client # noqa: F401 from .const import (CALL_SERVICE, REGISTER) @@ -45,7 +44,7 @@ async def test_webocket_get_user_registrations(hass, aiohttp_client, async def test_webocket_delete_registration(hass, hass_client, - hass_ws_client, webhook_client): # noqa: E501 F811 + hass_ws_client, webhook_client): """Test delete_registration websocket command.""" authed_api_client = await hass_client() # noqa: F811 register_resp = await authed_api_client.post( diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 665be9b3477..3bbd4b013a5 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -3,6 +3,7 @@ from asynctest import patch import pytest from homeassistant.components import device_tracker +from homeassistant.components.device_tracker.const import ENTITY_ID_FORMAT from homeassistant.const import CONF_PLATFORM from homeassistant.setup import async_setup_component @@ -39,7 +40,7 @@ async def test_ensure_device_tracker_platform_validation(hass): async def test_new_message(hass, mock_device_tracker_conf): """Test new message.""" dev_id = 'paulus' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + entity_id = ENTITY_ID_FORMAT.format(dev_id) topic = '/location/paulus' location = 'work' @@ -58,7 +59,7 @@ async def test_new_message(hass, mock_device_tracker_conf): async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf): """Test single level wildcard topic.""" dev_id = 'paulus' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + entity_id = ENTITY_ID_FORMAT.format(dev_id) subscription = '/location/+/paulus' topic = '/location/room/paulus' location = 'work' @@ -78,7 +79,7 @@ async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf): async def test_multi_level_wildcard_topic(hass, mock_device_tracker_conf): """Test multi level wildcard topic.""" dev_id = 'paulus' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + entity_id = ENTITY_ID_FORMAT.format(dev_id) subscription = '/location/#' topic = '/location/room/paulus' location = 'work' @@ -99,7 +100,7 @@ async def test_single_level_wildcard_topic_not_matching( hass, mock_device_tracker_conf): """Test not matching single level wildcard topic.""" dev_id = 'paulus' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + entity_id = ENTITY_ID_FORMAT.format(dev_id) subscription = '/location/+/paulus' topic = '/location/paulus' location = 'work' @@ -120,7 +121,7 @@ async def test_multi_level_wildcard_topic_not_matching( hass, mock_device_tracker_conf): """Test not matching multi level wildcard topic.""" dev_id = 'paulus' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + entity_id = ENTITY_ID_FORMAT.format(dev_id) subscription = '/location/#' topic = '/somewhere/room/paulus' location = 'work' diff --git a/tests/components/mqtt_json/test_device_tracker.py b/tests/components/mqtt_json/test_device_tracker.py index ea87be42bd6..f6270258429 100644 --- a/tests/components/mqtt_json/test_device_tracker.py +++ b/tests/components/mqtt_json/test_device_tracker.py @@ -1,12 +1,13 @@ """The tests for the JSON MQTT device tracker platform.""" import json -from asynctest import patch import logging import os +from asynctest import patch import pytest from homeassistant.setup import async_setup_component -from homeassistant.components import device_tracker +from homeassistant.components.device_tracker.legacy import ( + YAML_DEVICES, ENTITY_ID_FORMAT, DOMAIN as DT_DOMAIN) from homeassistant.const import CONF_PLATFORM from tests.common import async_mock_mqtt_component, async_fire_mqtt_message @@ -27,7 +28,7 @@ LOCATION_MESSAGE_INCOMPLETE = { def setup_comp(hass): """Initialize components.""" hass.loop.run_until_complete(async_mock_mqtt_component(hass)) - yaml_devices = hass.config.path(device_tracker.YAML_DEVICES) + yaml_devices = hass.config.path(YAML_DEVICES) yield if os.path.isfile(yaml_devices): os.remove(yaml_devices) @@ -45,8 +46,8 @@ async def test_ensure_device_tracker_platform_validation(hass): dev_id = 'paulus' topic = 'location/paulus' - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { + assert await async_setup_component(hass, DT_DOMAIN, { + DT_DOMAIN: { CONF_PLATFORM: 'mqtt_json', 'devices': {dev_id: topic} } @@ -60,8 +61,8 @@ async def test_json_message(hass): topic = 'location/zanzito' location = json.dumps(LOCATION_MESSAGE) - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { + assert await async_setup_component(hass, DT_DOMAIN, { + DT_DOMAIN: { CONF_PLATFORM: 'mqtt_json', 'devices': {dev_id: topic} } @@ -79,8 +80,8 @@ async def test_non_json_message(hass, caplog): topic = 'location/zanzito' location = 'home' - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { + assert await async_setup_component(hass, DT_DOMAIN, { + DT_DOMAIN: { CONF_PLATFORM: 'mqtt_json', 'devices': {dev_id: topic} } @@ -100,8 +101,8 @@ async def test_incomplete_message(hass, caplog): topic = 'location/zanzito' location = json.dumps(LOCATION_MESSAGE_INCOMPLETE) - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { + assert await async_setup_component(hass, DT_DOMAIN, { + DT_DOMAIN: { CONF_PLATFORM: 'mqtt_json', 'devices': {dev_id: topic} } @@ -123,8 +124,8 @@ async def test_single_level_wildcard_topic(hass): topic = 'location/room/zanzito' location = json.dumps(LOCATION_MESSAGE) - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { + assert await async_setup_component(hass, DT_DOMAIN, { + DT_DOMAIN: { CONF_PLATFORM: 'mqtt_json', 'devices': {dev_id: subscription} } @@ -143,8 +144,8 @@ async def test_multi_level_wildcard_topic(hass): topic = 'location/zanzito' location = json.dumps(LOCATION_MESSAGE) - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { + assert await async_setup_component(hass, DT_DOMAIN, { + DT_DOMAIN: { CONF_PLATFORM: 'mqtt_json', 'devices': {dev_id: subscription} } @@ -159,13 +160,13 @@ async def test_multi_level_wildcard_topic(hass): async def test_single_level_wildcard_topic_not_matching(hass): """Test not matching single level wildcard topic.""" dev_id = 'zanzito' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + entity_id = ENTITY_ID_FORMAT.format(dev_id) subscription = 'location/+/zanzito' topic = 'location/zanzito' location = json.dumps(LOCATION_MESSAGE) - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { + assert await async_setup_component(hass, DT_DOMAIN, { + DT_DOMAIN: { CONF_PLATFORM: 'mqtt_json', 'devices': {dev_id: subscription} } @@ -178,13 +179,13 @@ async def test_single_level_wildcard_topic_not_matching(hass): async def test_multi_level_wildcard_topic_not_matching(hass): """Test not matching multi level wildcard topic.""" dev_id = 'zanzito' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + entity_id = ENTITY_ID_FORMAT.format(dev_id) subscription = 'location/#' topic = 'somewhere/zanzito' location = json.dumps(LOCATION_MESSAGE) - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { + assert await async_setup_component(hass, DT_DOMAIN, { + DT_DOMAIN: { CONF_PLATFORM: 'mqtt_json', 'devices': {dev_id: subscription} } diff --git a/tests/components/owntracks/test_config_flow.py b/tests/components/owntracks/test_config_flow.py index 079fdfafea0..57f4cfd354e 100644 --- a/tests/components/owntracks/test_config_flow.py +++ b/tests/components/owntracks/test_config_flow.py @@ -1 +1,61 @@ """Tests for OwnTracks config flow.""" +from unittest.mock import patch + +from homeassistant.setup import async_setup_component +from tests.common import mock_coro + + +async def test_config_flow_import(hass): + """Test that we automatically create a config flow.""" + assert not hass.config_entries.async_entries('owntracks') + assert await async_setup_component(hass, 'owntracks', { + 'owntracks': { + + } + }) + await hass.async_block_till_done() + assert hass.config_entries.async_entries('owntracks') + + +async def test_config_flow_unload(hass): + """Test unloading a config flow.""" + with patch('homeassistant.config_entries.ConfigEntries' + '.async_forward_entry_setup') as mock_forward: + result = await hass.config_entries.flow.async_init( + 'owntracks', context={'source': 'import'}, + data={} + ) + + assert len(mock_forward.mock_calls) == 1 + entry = result['result'] + + assert mock_forward.mock_calls[0][1][0] is entry + assert mock_forward.mock_calls[0][1][1] == 'device_tracker' + assert entry.data['webhook_id'] in hass.data['webhook'] + + with patch('homeassistant.config_entries.ConfigEntries' + '.async_forward_entry_unload', return_value=mock_coro() + ) as mock_unload: + assert await hass.config_entries.async_unload(entry.entry_id) + + assert len(mock_unload.mock_calls) == 1 + assert mock_forward.mock_calls[0][1][0] is entry + assert mock_forward.mock_calls[0][1][1] == 'device_tracker' + assert entry.data['webhook_id'] not in hass.data['webhook'] + + +async def test_with_cloud_sub(hass): + """Test creating a config flow while subscribed.""" + with patch('homeassistant.components.cloud.async_active_subscription', + return_value=True), \ + patch('homeassistant.components.cloud.async_create_cloudhook', + return_value=mock_coro('https://hooks.nabu.casa/ABCD')): + result = await hass.config_entries.flow.async_init( + 'owntracks', context={'source': 'user'}, + data={} + ) + + entry = result['result'] + assert entry.data['cloudhook'] + assert result['description_placeholders']['webhook_url'] == \ + 'https://hooks.nabu.casa/ABCD' diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index 8e868296703..7d8d48de586 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -861,10 +861,9 @@ async def test_event_beacon_unknown_zone_no_location(hass, context): # the Device during test case setup. assert_location_state(hass, 'None') - # home is the state of a Device constructed through - # the normal code path on it's first observation with - # the conditions I pass along. - assert_mobile_tracker_state(hass, 'home', 'unknown') + # We have had no location yet, so the beacon status + # set to unknown. + assert_mobile_tracker_state(hass, 'unknown', 'unknown') async def test_event_beacon_unknown_zone(hass, context): @@ -1276,7 +1275,7 @@ async def test_single_waypoint_import(hass, context): async def test_not_implemented_message(hass, context): """Handle not implemented message type.""" patch_handler = patch('homeassistant.components.owntracks.' - 'device_tracker.async_handle_not_impl_msg', + 'messages.async_handle_not_impl_msg', return_value=mock_coro(False)) patch_handler.start() assert not await send_message(hass, LWT_TOPIC, LWT_MESSAGE) @@ -1286,7 +1285,7 @@ async def test_not_implemented_message(hass, context): async def test_unsupported_message(hass, context): """Handle not implemented message type.""" patch_handler = patch('homeassistant.components.owntracks.' - 'device_tracker.async_handle_unsupported_msg', + 'messages.async_handle_unsupported_msg', return_value=mock_coro(False)) patch_handler.start() assert not await send_message(hass, BAD_TOPIC, BAD_MESSAGE) @@ -1374,7 +1373,7 @@ def config_context(hass, setup_comp): patch_save.stop() -@patch('homeassistant.components.owntracks.device_tracker.get_cipher', +@patch('homeassistant.components.owntracks.messages.get_cipher', mock_cipher) async def test_encrypted_payload(hass, setup_comp): """Test encrypted payload.""" @@ -1385,7 +1384,7 @@ async def test_encrypted_payload(hass, setup_comp): assert_location_latitude(hass, LOCATION_MESSAGE['lat']) -@patch('homeassistant.components.owntracks.device_tracker.get_cipher', +@patch('homeassistant.components.owntracks.messages.get_cipher', mock_cipher) async def test_encrypted_payload_topic_key(hass, setup_comp): """Test encrypted payload with a topic key.""" @@ -1398,7 +1397,7 @@ async def test_encrypted_payload_topic_key(hass, setup_comp): assert_location_latitude(hass, LOCATION_MESSAGE['lat']) -@patch('homeassistant.components.owntracks.device_tracker.get_cipher', +@patch('homeassistant.components.owntracks.messages.get_cipher', mock_cipher) async def test_encrypted_payload_no_key(hass, setup_comp): """Test encrypted payload with no key, .""" @@ -1411,7 +1410,7 @@ async def test_encrypted_payload_no_key(hass, setup_comp): assert hass.states.get(DEVICE_TRACKER_STATE) is None -@patch('homeassistant.components.owntracks.device_tracker.get_cipher', +@patch('homeassistant.components.owntracks.messages.get_cipher', mock_cipher) async def test_encrypted_payload_wrong_key(hass, setup_comp): """Test encrypted payload with wrong key.""" @@ -1422,7 +1421,7 @@ async def test_encrypted_payload_wrong_key(hass, setup_comp): assert hass.states.get(DEVICE_TRACKER_STATE) is None -@patch('homeassistant.components.owntracks.device_tracker.get_cipher', +@patch('homeassistant.components.owntracks.messages.get_cipher', mock_cipher) async def test_encrypted_payload_wrong_topic_key(hass, setup_comp): """Test encrypted payload with wrong topic key.""" @@ -1435,7 +1434,7 @@ async def test_encrypted_payload_wrong_topic_key(hass, setup_comp): assert hass.states.get(DEVICE_TRACKER_STATE) is None -@patch('homeassistant.components.owntracks.device_tracker.get_cipher', +@patch('homeassistant.components.owntracks.messages.get_cipher', mock_cipher) async def test_encrypted_payload_no_topic_key(hass, setup_comp): """Test encrypted payload with no topic key.""" @@ -1492,3 +1491,47 @@ async def test_region_mapping(hass, setup_comp): await send_message(hass, EVENT_TOPIC, message) assert_location_state(hass, 'inner') + + +async def test_restore_state(hass, hass_client): + """Test that we can restore state.""" + entry = MockConfigEntry(domain='owntracks', data={ + 'webhook_id': 'owntracks_test', + 'secret': 'abcd', + }) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + client = await hass_client() + resp = await client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) + assert resp.status == 200 + await hass.async_block_till_done() + + state_1 = hass.states.get('device_tracker.paulus_pixel') + assert state_1 is not None + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + state_2 = hass.states.get('device_tracker.paulus_pixel') + assert state_2 is not None + + assert state_1 is not state_2 + + assert state_1.state == state_2.state + assert state_1.name == state_2.name + assert state_1.attributes['latitude'] == state_2.attributes['latitude'] + assert state_1.attributes['longitude'] == state_2.attributes['longitude'] + assert state_1.attributes['battery_level'] == \ + state_2.attributes['battery_level'] + assert state_1.attributes['source_type'] == \ + state_2.attributes['source_type'] diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py index 3d2d8d03e7c..b662bbcd6bd 100644 --- a/tests/components/owntracks/test_init.py +++ b/tests/components/owntracks/test_init.py @@ -4,7 +4,7 @@ import asyncio import pytest from homeassistant.setup import async_setup_component - +from homeassistant.components import owntracks from tests.common import mock_component, MockConfigEntry MINIMAL_LOCATION_MESSAGE = { @@ -162,13 +162,22 @@ def test_returns_error_missing_device(mock_client): assert json == [] -async def test_config_flow_import(hass): - """Test that we automatically create a config flow.""" - assert not hass.config_entries.async_entries('owntracks') - assert await async_setup_component(hass, 'owntracks', { - 'owntracks': { +def test_context_delivers_pending_msg(): + """Test that context is able to hold pending messages while being init.""" + context = owntracks.OwnTracksContext( + None, None, None, None, None, None, None, None + ) + context.async_see(hello='world') + context.async_see(world='hello') + received = [] - } - }) - await hass.async_block_till_done() - assert hass.config_entries.async_entries('owntracks') + context.set_async_see(lambda **data: received.append(data)) + + assert len(received) == 2 + assert received[0] == {'hello': 'world'} + assert received[1] == {'world': 'hello'} + + received.clear() + + context.set_async_see(lambda **data: received.append(data)) + assert len(received) == 0 diff --git a/tests/components/sensor/test_pilight.py b/tests/components/pilight/test_sensor.py similarity index 100% rename from tests/components/sensor/test_pilight.py rename to tests/components/pilight/test_sensor.py diff --git a/tests/components/cover/test_rfxtrx.py b/tests/components/rfxtrx/test_cover.py similarity index 100% rename from tests/components/cover/test_rfxtrx.py rename to tests/components/rfxtrx/test_cover.py diff --git a/tests/components/light/test_rfxtrx.py b/tests/components/rfxtrx/test_light.py similarity index 100% rename from tests/components/light/test_rfxtrx.py rename to tests/components/rfxtrx/test_light.py diff --git a/tests/components/sensor/test_rfxtrx.py b/tests/components/rfxtrx/test_sensor.py similarity index 100% rename from tests/components/sensor/test_rfxtrx.py rename to tests/components/rfxtrx/test_sensor.py diff --git a/tests/components/switch/test_rfxtrx.py b/tests/components/rfxtrx/test_switch.py similarity index 100% rename from tests/components/switch/test_rfxtrx.py rename to tests/components/rfxtrx/test_switch.py diff --git a/tests/components/scene/test_init.py b/tests/components/scene/test_init.py index 940999c2dbe..99364d51e6c 100644 --- a/tests/components/scene/test_init.py +++ b/tests/components/scene/test_init.py @@ -4,7 +4,7 @@ import unittest from homeassistant.setup import setup_component from homeassistant.components import light, scene -from homeassistant.util import yaml +from homeassistant.util.yaml import loader as yaml_loader from tests.common import get_test_home_assistant from tests.components.light import common as common_light @@ -90,7 +90,7 @@ class TestScene(unittest.TestCase): self.light_1.entity_id, self.light_2.entity_id) with io.StringIO(config) as file: - doc = yaml.yaml.safe_load(file) + doc = yaml_loader.yaml.load(file) assert setup_component(self.hass, scene.DOMAIN, doc) common.activate(self.hass, 'scene.test') diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 790d5c2e844..c2ff17d9444 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -3,6 +3,8 @@ import unittest from unittest.mock import patch, Mock +import pytest + from homeassistant.components import script from homeassistant.components.script import DOMAIN from homeassistant.const import ( @@ -11,6 +13,7 @@ from homeassistant.const import ( from homeassistant.core import Context, callback, split_entity_id from homeassistant.loader import bind_hass from homeassistant.setup import setup_component, async_setup_component +from homeassistant.exceptions import ServiceNotFound from tests.common import get_test_home_assistant @@ -300,3 +303,22 @@ async def test_shared_context(hass): state = hass.states.get('script.test') assert state is not None assert state.context == context + + +async def test_logging_script_error(hass, caplog): + """Test logging script error.""" + assert await async_setup_component(hass, 'script', { + 'script': { + 'hello': { + 'sequence': [ + {'service': 'non.existing'} + ] + } + } + }) + with pytest.raises(ServiceNotFound) as err: + await hass.services.async_call('script', 'hello', blocking=True) + + assert err.value.domain == 'non' + assert err.value.service == 'existing' + assert 'Error executing script' in caplog.text diff --git a/tests/components/ssdp/__init__.py b/tests/components/ssdp/__init__.py new file mode 100644 index 00000000000..b6dcb9d49b5 --- /dev/null +++ b/tests/components/ssdp/__init__.py @@ -0,0 +1 @@ +"""Tests for the SSDP integration.""" diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py new file mode 100644 index 00000000000..4b1e27d2dc8 --- /dev/null +++ b/tests/components/ssdp/test_init.py @@ -0,0 +1,107 @@ +"""Test the SSDP integration.""" +import asyncio +from unittest.mock import patch, Mock + +import aiohttp +import pytest + +from homeassistant.generated import ssdp as gn_ssdp +from homeassistant.components import ssdp + +from tests.common import mock_coro + + +async def test_scan_match_st(hass): + """Test matching based on ST.""" + scanner = ssdp.Scanner(hass) + + with patch('netdisco.ssdp.scan', return_value=[ + Mock(st="mock-st", location=None) + ]), patch.dict( + gn_ssdp.SSDP['st'], {'mock-st': ['mock-domain']} + ), patch.object( + hass.config_entries.flow, 'async_init', + return_value=mock_coro() + ) as mock_init: + await scanner.async_scan(None) + + assert len(mock_init.mock_calls) == 1 + assert mock_init.mock_calls[0][1][0] == 'mock-domain' + assert mock_init.mock_calls[0][2]['context'] == {'source': 'ssdp'} + + +async def test_scan_match_manufacturer(hass, aioclient_mock): + """Test matching based on ST.""" + aioclient_mock.get('http://1.1.1.1', text=""" + + + Paulus + + + """) + scanner = ssdp.Scanner(hass) + + with patch('netdisco.ssdp.scan', return_value=[ + Mock(st="mock-st", location='http://1.1.1.1') + ]), patch.dict( + gn_ssdp.SSDP['manufacturer'], {'Paulus': ['mock-domain']} + ), patch.object( + hass.config_entries.flow, 'async_init', + return_value=mock_coro() + ) as mock_init: + await scanner.async_scan(None) + + assert len(mock_init.mock_calls) == 1 + assert mock_init.mock_calls[0][1][0] == 'mock-domain' + assert mock_init.mock_calls[0][2]['context'] == {'source': 'ssdp'} + + +async def test_scan_match_device_type(hass, aioclient_mock): + """Test matching based on ST.""" + aioclient_mock.get('http://1.1.1.1', text=""" + + + Paulus + + + """) + scanner = ssdp.Scanner(hass) + + with patch('netdisco.ssdp.scan', return_value=[ + Mock(st="mock-st", location='http://1.1.1.1') + ]), patch.dict( + gn_ssdp.SSDP['device_type'], {'Paulus': ['mock-domain']} + ), patch.object( + hass.config_entries.flow, 'async_init', + return_value=mock_coro() + ) as mock_init: + await scanner.async_scan(None) + + assert len(mock_init.mock_calls) == 1 + assert mock_init.mock_calls[0][1][0] == 'mock-domain' + assert mock_init.mock_calls[0][2]['context'] == {'source': 'ssdp'} + + +@pytest.mark.parametrize('exc', [asyncio.TimeoutError, aiohttp.ClientError]) +async def test_scan_description_fetch_fail(hass, aioclient_mock, exc): + """Test failing to fetch description.""" + aioclient_mock.get('http://1.1.1.1', exc=exc) + scanner = ssdp.Scanner(hass) + + with patch('netdisco.ssdp.scan', return_value=[ + Mock(st="mock-st", location='http://1.1.1.1') + ]): + await scanner.async_scan(None) + + +async def test_scan_description_parse_fail(hass, aioclient_mock): + """Test invalid XML.""" + aioclient_mock.get('http://1.1.1.1', text=""" +INVALIDXML + """) + scanner = ssdp.Scanner(hass) + + with patch('netdisco.ssdp.scan', return_value=[ + Mock(st="mock-st", location='http://1.1.1.1') + ]): + await scanner.async_scan(None) diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py index 2833efa62c4..374527e2c8a 100644 --- a/tests/components/sun/test_init.py +++ b/tests/components/sun/test_init.py @@ -1,153 +1,177 @@ """The tests for the Sun component.""" -# pylint: disable=protected-access -import unittest +from datetime import datetime, timedelta from unittest.mock import patch -from datetime import timedelta, datetime -from homeassistant.setup import setup_component +from pytest import mark + +import homeassistant.components.sun as sun import homeassistant.core as ha import homeassistant.util.dt as dt_util -import homeassistant.components.sun as sun - -from tests.common import get_test_home_assistant +from homeassistant.const import EVENT_STATE_CHANGED +from homeassistant.setup import async_setup_component -# pylint: disable=invalid-name -class TestSun(unittest.TestCase): - """Test the sun module.""" +async def test_setting_rising(hass): + """Test retrieving sun setting and rising.""" + utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) + with patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=utc_now): + await async_setup_component(hass, sun.DOMAIN, { + sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + await hass.async_block_till_done() + state = hass.states.get(sun.ENTITY_ID) - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() + from astral import Astral - def test_setting_rising(self): - """Test retrieving sun setting and rising.""" - utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) - with patch('homeassistant.helpers.condition.dt_util.utcnow', - return_value=utc_now): - setup_component(self.hass, sun.DOMAIN, { - sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + astral = Astral() + utc_today = utc_now.date() - self.hass.block_till_done() - state = self.hass.states.get(sun.ENTITY_ID) + latitude = hass.config.latitude + longitude = hass.config.longitude - from astral import Astral + mod = -1 + while True: + next_dawn = (astral.dawn_utc( + utc_today + timedelta(days=mod), latitude, longitude)) + if next_dawn > utc_now: + break + mod += 1 - astral = Astral() - utc_today = utc_now.date() + mod = -1 + while True: + next_dusk = (astral.dusk_utc( + utc_today + timedelta(days=mod), latitude, longitude)) + if next_dusk > utc_now: + break + mod += 1 - latitude = self.hass.config.latitude - longitude = self.hass.config.longitude + mod = -1 + while True: + next_midnight = (astral.solar_midnight_utc( + utc_today + timedelta(days=mod), longitude)) + if next_midnight > utc_now: + break + mod += 1 - mod = -1 - while True: - next_dawn = (astral.dawn_utc( - utc_today + timedelta(days=mod), latitude, longitude)) - if next_dawn > utc_now: - break - mod += 1 + mod = -1 + while True: + next_noon = (astral.solar_noon_utc( + utc_today + timedelta(days=mod), longitude)) + if next_noon > utc_now: + break + mod += 1 - mod = -1 - while True: - next_dusk = (astral.dusk_utc( - utc_today + timedelta(days=mod), latitude, longitude)) - if next_dusk > utc_now: - break - mod += 1 + mod = -1 + while True: + next_rising = (astral.sunrise_utc( + utc_today + timedelta(days=mod), latitude, longitude)) + if next_rising > utc_now: + break + mod += 1 - mod = -1 - while True: - next_midnight = (astral.solar_midnight_utc( - utc_today + timedelta(days=mod), longitude)) - if next_midnight > utc_now: - break - mod += 1 + mod = -1 + while True: + next_setting = (astral.sunset_utc( + utc_today + timedelta(days=mod), latitude, longitude)) + if next_setting > utc_now: + break + mod += 1 - mod = -1 - while True: - next_noon = (astral.solar_noon_utc( - utc_today + timedelta(days=mod), longitude)) - if next_noon > utc_now: - break - mod += 1 + assert next_dawn == dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_DAWN]) + assert next_dusk == dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_DUSK]) + assert next_midnight == dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_MIDNIGHT]) + assert next_noon == dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_NOON]) + assert next_rising == dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_RISING]) + assert next_setting == dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_SETTING]) - mod = -1 - while True: - next_rising = (astral.sunrise_utc( - utc_today + timedelta(days=mod), latitude, longitude)) - if next_rising > utc_now: - break - mod += 1 - mod = -1 - while True: - next_setting = (astral.sunset_utc( - utc_today + timedelta(days=mod), latitude, longitude)) - if next_setting > utc_now: - break - mod += 1 +async def test_state_change(hass): + """Test if the state changes at next setting/rising.""" + now = datetime(2016, 6, 1, 8, 0, 0, tzinfo=dt_util.UTC) + with patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=now): + await async_setup_component(hass, sun.DOMAIN, { + sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) - assert next_dawn == dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_DAWN]) - assert next_dusk == dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_DUSK]) - assert next_midnight == dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_MIDNIGHT]) - assert next_noon == dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_NOON]) - assert next_rising == dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_RISING]) - assert next_setting == dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_SETTING]) + await hass.async_block_till_done() - def test_state_change(self): - """Test if the state changes at next setting/rising.""" - now = datetime(2016, 6, 1, 8, 0, 0, tzinfo=dt_util.UTC) - with patch('homeassistant.helpers.condition.dt_util.utcnow', - return_value=now): - setup_component(self.hass, sun.DOMAIN, { - sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + test_time = dt_util.parse_datetime( + hass.states.get(sun.ENTITY_ID) + .attributes[sun.STATE_ATTR_NEXT_RISING]) + assert test_time is not None - self.hass.block_till_done() + assert sun.STATE_BELOW_HORIZON == \ + hass.states.get(sun.ENTITY_ID).state - test_time = dt_util.parse_datetime( - self.hass.states.get(sun.ENTITY_ID) - .attributes[sun.STATE_ATTR_NEXT_RISING]) - assert test_time is not None + hass.bus.async_fire( + ha.EVENT_TIME_CHANGED, + {ha.ATTR_NOW: test_time + timedelta(seconds=5)}) - assert sun.STATE_BELOW_HORIZON == \ - self.hass.states.get(sun.ENTITY_ID).state + await hass.async_block_till_done() - self.hass.bus.fire(ha.EVENT_TIME_CHANGED, - {ha.ATTR_NOW: test_time + timedelta(seconds=5)}) + assert sun.STATE_ABOVE_HORIZON == \ + hass.states.get(sun.ENTITY_ID).state - self.hass.block_till_done() - assert sun.STATE_ABOVE_HORIZON == \ - self.hass.states.get(sun.ENTITY_ID).state +async def test_norway_in_june(hass): + """Test location in Norway where the sun doesn't set in summer.""" + hass.config.latitude = 69.6 + hass.config.longitude = 18.8 - def test_norway_in_june(self): - """Test location in Norway where the sun doesn't set in summer.""" - self.hass.config.latitude = 69.6 - self.hass.config.longitude = 18.8 + june = datetime(2016, 6, 1, tzinfo=dt_util.UTC) - june = datetime(2016, 6, 1, tzinfo=dt_util.UTC) + with patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=june): + assert await async_setup_component(hass, sun.DOMAIN, { + sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) - with patch('homeassistant.helpers.condition.dt_util.utcnow', - return_value=june): - assert setup_component(self.hass, sun.DOMAIN, { - sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + state = hass.states.get(sun.ENTITY_ID) + assert state is not None - state = self.hass.states.get(sun.ENTITY_ID) - assert state is not None + assert dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_RISING]) == \ + datetime(2016, 7, 25, 23, 23, 39, tzinfo=dt_util.UTC) + assert dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_SETTING]) == \ + datetime(2016, 7, 26, 22, 19, 1, tzinfo=dt_util.UTC) - assert dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_RISING]) == \ - datetime(2016, 7, 25, 23, 23, 39, tzinfo=dt_util.UTC) - assert dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_SETTING]) == \ - datetime(2016, 7, 26, 22, 19, 1, tzinfo=dt_util.UTC) + +@mark.skip +async def test_state_change_count(hass): + """Count the number of state change events in a location.""" + # Skipped because it's a bit slow. Has been validated with + # multiple lattitudes and dates + hass.config.latitude = 10 + hass.config.longitude = 0 + + now = datetime(2016, 6, 1, tzinfo=dt_util.UTC) + + with patch( + 'homeassistant.helpers.condition.dt_util.utcnow', + return_value=now): + assert await async_setup_component(hass, sun.DOMAIN, { + sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + + events = [] + @ha.callback + def state_change_listener(event): + if event.data.get('entity_id') == 'sun.sun': + events.append(event) + hass.bus.async_listen(EVENT_STATE_CHANGED, state_change_listener) + await hass.async_block_till_done() + + for _ in range(24*60*60): + now += timedelta(seconds=1) + hass.bus.async_fire( + ha.EVENT_TIME_CHANGED, + {ha.ATTR_NOW: now}) + await hass.async_block_till_done() + + assert len(events) < 721 diff --git a/tests/components/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py index d0398d448e9..9f961f72401 100644 --- a/tests/components/switcher_kis/conftest.py +++ b/tests/components/switcher_kis/conftest.py @@ -108,3 +108,25 @@ def mock_bridge_fixture() -> Generator[None, Any, None]: for patcher in patchers: patcher.stop() + + +@fixture(name='mock_failed_bridge') +def mock_failed_bridge_fixture() -> Generator[None, Any, None]: + """Fixture for mocking aioswitcher.bridge.SwitcherV2Bridge.""" + async def mock_queue(): + """Mock asyncio's Queue.""" + raise RuntimeError + + patchers = [ + patch('aioswitcher.bridge.SwitcherV2Bridge.start', return_value=None), + patch('aioswitcher.bridge.SwitcherV2Bridge.stop', return_value=None), + patch('aioswitcher.bridge.SwitcherV2Bridge.queue', get=mock_queue) + ] + + for patcher in patchers: + patcher.start() + + yield + + for patcher in patchers: + patcher.stop() diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py index 0defb113747..33d24903f94 100644 --- a/tests/components/switcher_kis/test_init.py +++ b/tests/components/switcher_kis/test_init.py @@ -13,7 +13,9 @@ from .consts import ( DUMMY_REMAINING_TIME, MANDATORY_CONFIGURATION) -async def test_failed_config(hass: HomeAssistantType) -> None: +async def test_failed_config( + hass: HomeAssistantType, + mock_failed_bridge: Generator[None, Any, None]) -> None: """Test failed configuration.""" assert await async_setup_component( hass, DOMAIN, MANDATORY_CONFIGURATION) is False diff --git a/tests/components/tplink/test_device_tracker.py b/tests/components/tplink/test_device_tracker.py index f1d60d46762..d7676b51d72 100644 --- a/tests/components/tplink/test_device_tracker.py +++ b/tests/components/tplink/test_device_tracker.py @@ -3,7 +3,7 @@ import os import pytest -from homeassistant.components import device_tracker +from homeassistant.components.device_tracker.legacy import YAML_DEVICES from homeassistant.components.tplink.device_tracker import Tplink4DeviceScanner from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, CONF_HOST) @@ -13,7 +13,7 @@ import requests_mock @pytest.fixture(autouse=True) def setup_comp(hass): """Initialize components.""" - yaml_devices = hass.config.path(device_tracker.YAML_DEVICES) + yaml_devices = hass.config.path(YAML_DEVICES) yield if os.path.isfile(yaml_devices): os.remove(yaml_devices) diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index 6756a01bbc7..8fcc72dd4a5 100644 --- a/tests/components/tradfri/test_config_flow.py +++ b/tests/components/tradfri/test_config_flow.py @@ -99,7 +99,7 @@ async def test_discovery_connection(hass, mock_auth, mock_entry_setup): }) flow = await hass.config_entries.flow.async_init( - 'tradfri', context={'source': 'discovery'}, data={ + 'tradfri', context={'source': 'zeroconf'}, data={ 'host': '123.123.123.123' }) @@ -249,7 +249,7 @@ async def test_discovery_duplicate_aborted(hass): ).add_to_hass(hass) flow = await hass.config_entries.flow.async_init( - 'tradfri', context={'source': 'discovery'}, data={ + 'tradfri', context={'source': 'zeroconf'}, data={ 'host': 'some-host' }) diff --git a/tests/components/light/test_tradfri.py b/tests/components/tradfri/test_light.py similarity index 100% rename from tests/components/light/test_tradfri.py rename to tests/components/tradfri/test_light.py diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 140a938201b..b34d74bd0c6 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -607,6 +607,10 @@ async def test_setup_component_and_web_get_url(hass, hass_client): ("{}/api/tts_proxy/265944c108cbb00b2a62" "1be5930513e03a0bb2cd_en_-_demo.mp3".format(hass.config.api.base_url)) + tts_cache = hass.config.path(tts.DEFAULT_CACHE_DIR) + if os.path.isdir(tts_cache): + shutil.rmtree(tts_cache) + async def test_setup_component_and_web_get_url_bad_config(hass, hass_client): """Set up the demo platform and receive wrong file from web.""" diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index e5e1d84bfcd..d1db25a23cd 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -4,24 +4,27 @@ from unittest.mock import Mock, patch import pytest from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.components import unifi +from homeassistant.components.unifi.const import ( + CONF_POE_CONTROL, 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 from tests.common import mock_coro CONTROLLER_DATA = { - unifi.CONF_HOST: '1.2.3.4', - unifi.CONF_USERNAME: 'username', - unifi.CONF_PASSWORD: 'password', - unifi.CONF_PORT: 1234, - unifi.CONF_SITE_ID: 'site', - unifi.CONF_VERIFY_SSL: True + CONF_HOST: '1.2.3.4', + CONF_USERNAME: 'username', + CONF_PASSWORD: 'password', + CONF_PORT: 1234, + CONF_SITE_ID: 'site', + CONF_VERIFY_SSL: True } ENTRY_CONFIG = { - unifi.CONF_CONTROLLER: CONTROLLER_DATA, - unifi.CONF_POE_CONTROL: True - } + CONF_CONTROLLER: CONTROLLER_DATA, + CONF_POE_CONTROL: True +} async def test_controller_setup(): @@ -173,7 +176,7 @@ async def test_reset_unloads_entry_without_poe_control(): hass = Mock() entry = Mock() entry.data = dict(ENTRY_CONFIG) - entry.data[unifi.CONF_POE_CONTROL] = False + entry.data[CONF_POE_CONTROL] = False api = Mock() api.initialize.return_value = mock_coro(True) @@ -201,7 +204,7 @@ async def test_get_controller(hass): async def test_get_controller_verify_ssl_false(hass): """Successful call with verify ssl set to false.""" controller_data = dict(CONTROLLER_DATA) - controller_data[unifi.CONF_VERIFY_SSL] = False + controller_data[CONF_VERIFY_SSL] = False with patch('aiounifi.Controller.login', return_value=mock_coro()): assert await controller.get_controller(hass, **controller_data) diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index 0115801eec6..d2d19204b40 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -2,7 +2,12 @@ from unittest.mock import Mock, patch 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.const import ( + CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL) from tests.common import mock_coro, MockConfigEntry @@ -137,7 +142,7 @@ async def test_unload_entry(hass): async def test_flow_works(hass, aioclient_mock): """Test config flow.""" - flow = unifi.UnifiFlowHandler() + flow = config_flow.UnifiFlowHandler() flow.hass = hass with patch('aiounifi.Controller') as mock_controller: @@ -157,11 +162,11 @@ async def test_flow_works(hass, aioclient_mock): }) await flow.async_step_user(user_input={ - unifi.CONF_HOST: '1.2.3.4', - unifi.CONF_USERNAME: 'username', - unifi.CONF_PASSWORD: 'password', - unifi.CONF_PORT: 1234, - unifi.CONF_VERIFY_SSL: True + CONF_HOST: '1.2.3.4', + CONF_USERNAME: 'username', + CONF_PASSWORD: 'password', + CONF_PORT: 1234, + CONF_VERIFY_SSL: True }) result = await flow.async_step_site(user_input={}) @@ -173,27 +178,27 @@ async def test_flow_works(hass, aioclient_mock): assert result['type'] == 'create_entry' assert result['title'] == 'site name' assert result['data'] == { - unifi.CONF_CONTROLLER: { - unifi.CONF_HOST: '1.2.3.4', - unifi.CONF_USERNAME: 'username', - unifi.CONF_PASSWORD: 'password', - unifi.CONF_PORT: 1234, - unifi.CONF_SITE_ID: 'default', - unifi.CONF_VERIFY_SSL: True + CONF_CONTROLLER: { + CONF_HOST: '1.2.3.4', + CONF_USERNAME: 'username', + CONF_PASSWORD: 'password', + CONF_PORT: 1234, + CONF_SITE_ID: 'default', + CONF_VERIFY_SSL: True }, - unifi.CONF_POE_CONTROL: True + CONF_POE_CONTROL: True } async def test_controller_multiple_sites(hass): """Test config flow.""" - flow = unifi.UnifiFlowHandler() + flow = config_flow.UnifiFlowHandler() flow.hass = hass flow.config = { - unifi.CONF_HOST: '1.2.3.4', - unifi.CONF_USERNAME: 'username', - unifi.CONF_PASSWORD: 'password', + CONF_HOST: '1.2.3.4', + CONF_USERNAME: 'username', + CONF_PASSWORD: 'password', } flow.sites = { 'site1': { @@ -215,7 +220,7 @@ async def test_controller_multiple_sites(hass): async def test_controller_site_already_configured(hass): """Test config flow.""" - flow = unifi.UnifiFlowHandler() + flow = config_flow.UnifiFlowHandler() flow.hass = hass entry = MockConfigEntry(domain=unifi.DOMAIN, data={ @@ -227,9 +232,9 @@ async def test_controller_site_already_configured(hass): entry.add_to_hass(hass) flow.config = { - unifi.CONF_HOST: '1.2.3.4', - unifi.CONF_USERNAME: 'username', - unifi.CONF_PASSWORD: 'password', + CONF_HOST: '1.2.3.4', + CONF_USERNAME: 'username', + CONF_PASSWORD: 'password', } flow.desc = 'site name' flow.sites = { @@ -245,7 +250,7 @@ async def test_controller_site_already_configured(hass): async def test_user_permissions_low(hass, aioclient_mock): """Test config flow.""" - flow = unifi.UnifiFlowHandler() + flow = config_flow.UnifiFlowHandler() flow.hass = hass with patch('aiounifi.Controller') as mock_controller: @@ -265,11 +270,11 @@ async def test_user_permissions_low(hass, aioclient_mock): }) await flow.async_step_user(user_input={ - unifi.CONF_HOST: '1.2.3.4', - unifi.CONF_USERNAME: 'username', - unifi.CONF_PASSWORD: 'password', - unifi.CONF_PORT: 1234, - unifi.CONF_VERIFY_SSL: True + CONF_HOST: '1.2.3.4', + CONF_USERNAME: 'username', + CONF_PASSWORD: 'password', + CONF_PORT: 1234, + CONF_VERIFY_SSL: True }) result = await flow.async_step_site(user_input={}) @@ -279,16 +284,16 @@ async def test_user_permissions_low(hass, aioclient_mock): async def test_user_credentials_faulty(hass, aioclient_mock): """Test config flow.""" - flow = unifi.UnifiFlowHandler() + flow = config_flow.UnifiFlowHandler() flow.hass = hass - with patch.object(unifi, 'get_controller', + with patch.object(config_flow, 'get_controller', side_effect=unifi.errors.AuthenticationRequired): result = await flow.async_step_user({ - unifi.CONF_HOST: '1.2.3.4', - unifi.CONF_USERNAME: 'username', - unifi.CONF_PASSWORD: 'password', - unifi.CONF_SITE_ID: 'default', + CONF_HOST: '1.2.3.4', + CONF_USERNAME: 'username', + CONF_PASSWORD: 'password', + CONF_SITE_ID: 'default', }) assert result['type'] == 'form' @@ -297,16 +302,16 @@ async def test_user_credentials_faulty(hass, aioclient_mock): async def test_controller_is_unavailable(hass, aioclient_mock): """Test config flow.""" - flow = unifi.UnifiFlowHandler() + flow = config_flow.UnifiFlowHandler() flow.hass = hass - with patch.object(unifi, 'get_controller', + with patch.object(config_flow, 'get_controller', side_effect=unifi.errors.CannotConnect): result = await flow.async_step_user({ - unifi.CONF_HOST: '1.2.3.4', - unifi.CONF_USERNAME: 'username', - unifi.CONF_PASSWORD: 'password', - unifi.CONF_SITE_ID: 'default', + CONF_HOST: '1.2.3.4', + CONF_USERNAME: 'username', + CONF_PASSWORD: 'password', + CONF_SITE_ID: 'default', }) assert result['type'] == 'form' @@ -315,16 +320,16 @@ async def test_controller_is_unavailable(hass, aioclient_mock): async def test_controller_unkown_problem(hass, aioclient_mock): """Test config flow.""" - flow = unifi.UnifiFlowHandler() + flow = config_flow.UnifiFlowHandler() flow.hass = hass - with patch.object(unifi, 'get_controller', + with patch.object(config_flow, 'get_controller', side_effect=Exception): result = await flow.async_step_user({ - unifi.CONF_HOST: '1.2.3.4', - unifi.CONF_USERNAME: 'username', - unifi.CONF_PASSWORD: 'password', - unifi.CONF_SITE_ID: 'default', + CONF_HOST: '1.2.3.4', + CONF_USERNAME: 'username', + CONF_PASSWORD: 'password', + CONF_SITE_ID: 'default', }) assert result['type'] == 'abort' diff --git a/tests/components/switch/test_unifi.py b/tests/components/unifi/test_switch.py similarity index 96% rename from tests/components/switch/test_unifi.py rename to tests/components/unifi/test_switch.py index 67f1e416cf1..5a04b415f5d 100644 --- a/tests/components/switch/test_unifi.py +++ b/tests/components/unifi/test_switch.py @@ -10,7 +10,11 @@ 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.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 @@ -167,17 +171,17 @@ DEVICE_1 = { } CONTROLLER_DATA = { - unifi.CONF_HOST: 'mock-host', - unifi.CONF_USERNAME: 'mock-user', - unifi.CONF_PASSWORD: 'mock-pswd', - unifi.CONF_PORT: 1234, - unifi.CONF_SITE_ID: 'mock-site', - unifi.CONF_VERIFY_SSL: True + CONF_HOST: 'mock-host', + CONF_USERNAME: 'mock-user', + CONF_PASSWORD: 'mock-pswd', + CONF_PORT: 1234, + CONF_SITE_ID: 'mock-site', + CONF_VERIFY_SSL: True } ENTRY_CONFIG = { - unifi.CONF_CONTROLLER: CONTROLLER_DATA, - unifi.CONF_POE_CONTROL: True + CONF_CONTROLLER: CONTROLLER_DATA, + CONF_POE_CONTROL: True } CONTROLLER_ID = unifi.CONTROLLER_ID.format(host='mock-host', site='mock-site') diff --git a/tests/components/unifi_direct/test_device_tracker.py b/tests/components/unifi_direct/test_device_tracker.py index ba40a09aa59..9407642b162 100644 --- a/tests/components/unifi_direct/test_device_tracker.py +++ b/tests/components/unifi_direct/test_device_tracker.py @@ -7,7 +7,7 @@ import pytest import voluptuous as vol from homeassistant.setup import async_setup_component -from homeassistant.components import device_tracker +from homeassistant.components.device_tracker.legacy import YAML_DEVICES from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, CONF_TRACK_NEW, CONF_AWAY_HIDE, CONF_NEW_DEVICE_DEFAULTS) @@ -27,7 +27,7 @@ scanner_path = 'homeassistant.components.unifi_direct.device_tracker.' + \ def setup_comp(hass): """Initialize components.""" mock_component(hass, 'zone') - yaml_devices = hass.config.path(device_tracker.YAML_DEVICES) + yaml_devices = hass.config.path(YAML_DEVICES) yield if os.path.isfile(yaml_devices): os.remove(yaml_devices) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 4f3be31b22c..1487b6b8869 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -67,6 +67,30 @@ async def test_call_service_not_found(hass, websocket_client): assert msg['error']['code'] == const.ERR_NOT_FOUND +async def test_call_service_child_not_found(hass, websocket_client): + """Test not reporting not found errors if it's not the called service.""" + async def serv_handler(call): + await hass.services.async_call('non', 'existing') + + hass.services.async_register('domain_test', 'test_service', serv_handler) + + await websocket_client.send_json({ + 'id': 5, + 'type': 'call_service', + 'domain': 'domain_test', + 'service': 'test_service', + 'service_data': { + 'hello': 'world' + } + }) + + msg = await websocket_client.receive_json() + assert msg['id'] == 5 + assert msg['type'] == const.TYPE_RESULT + assert not msg['success'] + assert msg['error']['code'] == const.ERR_HOME_ASSISTANT_ERROR + + async def test_call_service_error(hass, websocket_client): """Test call service command with error.""" @callback @@ -134,7 +158,7 @@ async def test_subscribe_unsubscribe_events(hass, websocket_client): hass.bus.async_fire('test_event', {'hello': 'world'}) hass.bus.async_fire('ignore_event') - with timeout(3, loop=hass.loop): + with timeout(3): msg = await websocket_client.receive_json() assert msg['id'] == 5 @@ -365,7 +389,7 @@ async def test_subscribe_unsubscribe_events_whitelist( hass.bus.async_fire('themes_updated') - with timeout(3, loop=hass.loop): + with timeout(3): msg = await websocket_client.receive_json() assert msg['id'] == 6 diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py new file mode 100644 index 00000000000..eeac9af24cd --- /dev/null +++ b/tests/components/websocket_api/test_connection.py @@ -0,0 +1,30 @@ +"""Test WebSocket Connection class.""" +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api import const + + +async def test_send_big_result(hass, websocket_client): + """Test sending big results over the WS.""" + @websocket_api.websocket_command({ + 'type': 'big_result' + }) + @websocket_api.async_response + async def send_big_result(hass, connection, msg): + await connection.send_big_result( + msg['id'], {'big': 'result'} + ) + + hass.components.websocket_api.async_register_command( + send_big_result + ) + + await websocket_client.send_json({ + 'id': 5, + 'type': 'big_result', + }) + + msg = await websocket_client.receive_json() + assert msg['id'] == 5 + assert msg['type'] == const.TYPE_RESULT + assert msg['success'] + assert msg['result'] == {'big': 'result'} diff --git a/tests/components/zeroconf/__init__.py b/tests/components/zeroconf/__init__.py new file mode 100644 index 00000000000..d702ef482d6 --- /dev/null +++ b/tests/components/zeroconf/__init__.py @@ -0,0 +1 @@ +"""Tests for the Zeroconf component.""" diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py new file mode 100644 index 00000000000..27c1dc75749 --- /dev/null +++ b/tests/components/zeroconf/test_init.py @@ -0,0 +1,74 @@ +"""Test Zeroconf component setup process.""" +from unittest.mock import patch + +import pytest +from zeroconf import ServiceInfo, ServiceStateChange + +from homeassistant.generated import zeroconf as zc_gen +from homeassistant.setup import async_setup_component +from homeassistant.components import zeroconf + + +@pytest.fixture +def mock_zeroconf(): + """Mock zeroconf.""" + with patch('homeassistant.components.zeroconf.Zeroconf') as mock_zc: + yield mock_zc.return_value + + +def service_update_mock(zeroconf, service, handlers): + """Call service update handler.""" + handlers[0]( + zeroconf, service, '{}.{}'.format('name', service), + ServiceStateChange.Added) + + +def get_service_info_mock(service_type, name): + """Return service info for get_service_info.""" + return ServiceInfo( + service_type, name, address=b'\n\x00\x00\x14', port=80, weight=0, + priority=0, server='name.local.', + properties={b'macaddress': b'ABCDEF012345'}) + + +def get_homekit_info_mock(service_type, name): + """Return homekit info for get_service_info.""" + return ServiceInfo( + service_type, name, address=b'\n\x00\x00\x14', port=80, weight=0, + priority=0, server='name.local.', + properties={b'md': b'LIFX Bulb'}) + + +async def test_setup(hass, mock_zeroconf): + """Test configured options for a device are loaded via config entry.""" + with patch.object( + hass.config_entries, 'flow' + ) as mock_config_flow, patch.object( + zeroconf, 'ServiceBrowser', side_effect=service_update_mock + ) as mock_service_browser: + mock_zeroconf.get_service_info.side_effect = get_service_info_mock + assert await async_setup_component( + hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + + assert len(mock_service_browser.mock_calls) == len(zc_gen.ZEROCONF) + assert len(mock_config_flow.mock_calls) == len(zc_gen.ZEROCONF) * 2 + + +async def test_homekit(hass, mock_zeroconf): + """Test configured options for a device are loaded via config entry.""" + with patch.dict( + zc_gen.ZEROCONF, { + zeroconf.HOMEKIT_TYPE: ["homekit_controller"] + }, clear=True + ), patch.object( + hass.config_entries, 'flow' + ) as mock_config_flow, patch.object( + zeroconf, 'ServiceBrowser', side_effect=service_update_mock + ) as mock_service_browser: + mock_zeroconf.get_service_info.side_effect = get_homekit_info_mock + assert await async_setup_component( + hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 2 + assert mock_config_flow.mock_calls[0][1][0] == 'lifx' diff --git a/tests/components/zone/test_init.py b/tests/components/zone/test_init.py index ba98915e777..576be0ce03c 100644 --- a/tests/components/zone/test_init.py +++ b/tests/components/zone/test_init.py @@ -142,7 +142,7 @@ class TestComponentZone(unittest.TestCase): ] }) self.hass.block_till_done() - active = zone.zone.active_zone(self.hass, 32.880600, -117.237561) + active = zone.async_active_zone(self.hass, 32.880600, -117.237561) assert active is None def test_active_zone_skips_passive_zones_2(self): @@ -158,7 +158,7 @@ class TestComponentZone(unittest.TestCase): ] }) self.hass.block_till_done() - active = zone.zone.active_zone(self.hass, 32.880700, -117.237561) + active = zone.async_active_zone(self.hass, 32.880700, -117.237561) assert 'zone.active_zone' == active.entity_id def test_active_zone_prefers_smaller_zone_if_same_distance(self): @@ -182,7 +182,7 @@ class TestComponentZone(unittest.TestCase): ] }) - active = zone.zone.active_zone(self.hass, latitude, longitude) + active = zone.async_active_zone(self.hass, latitude, longitude) assert 'zone.small_zone' == active.entity_id def test_active_zone_prefers_smaller_zone_if_same_distance_2(self): @@ -200,7 +200,7 @@ class TestComponentZone(unittest.TestCase): ] }) - active = zone.zone.active_zone(self.hass, latitude, longitude) + active = zone.async_active_zone(self.hass, latitude, longitude) assert 'zone.smallest_zone' == active.entity_id def test_in_zone_works_for_passive_zones(self): diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index 7fc9f55cf03..69ee7c45a9b 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -226,6 +226,48 @@ async def test_device_entity(hass, mock_openzwave): assert device.device_state_attributes[zwave.ATTR_POWER] == 50.123 +async def test_node_removed(hass, mock_openzwave): + """Test node removed in base class.""" + # Create a mock node & node entity + node = MockNode(node_id='10', name='Mock Node') + value = MockValue(data=False, node=node, instance=2, object_id='11', + label='Sensor', + command_class=const.COMMAND_CLASS_SENSOR_BINARY) + power_value = MockValue(data=50.123456, node=node, precision=3, + command_class=const.COMMAND_CLASS_METER) + values = MockEntityValues(primary=value, power=power_value) + device = zwave.ZWaveDeviceEntity(values, 'zwave') + device.hass = hass + device.entity_id = 'zwave.mock_node' + device.value_added() + device.update_properties() + await hass.async_block_till_done() + + # Save it to the entity registry + registry = mock_registry(hass) + registry.async_get_or_create('zwave', 'zwave', device.unique_id) + device.entity_id = registry.async_get_entity_id( + 'zwave', 'zwave', device.unique_id) + + # Create dummy entity registry entries for other integrations + hue_entity = registry.async_get_or_create('light', 'hue', 1234) + zha_entity = registry.async_get_or_create('sensor', 'zha', 5678) + + # Verify our Z-Wave entity is registered + assert registry.async_is_registered(device.entity_id) + + # Remove it + entity_id = device.entity_id + await device.node_removed() + + # Verify registry entry for our Z-Wave node is gone + assert not registry.async_is_registered(entity_id) + + # Verify registry entries for our other entities remain + assert registry.async_is_registered(hue_entity.entity_id) + assert registry.async_is_registered(zha_entity.entity_id) + + async def test_node_discovery(hass, mock_openzwave): """Test discovery of a node.""" mock_receivers = [] diff --git a/tests/conftest.py b/tests/conftest.py index 4e567886ef0..83a175656d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,8 +44,6 @@ def check_real(func): # Guard a few functions that would make network connections location.async_detect_location_info = \ check_real(location.async_detect_location_info) -location.async_get_elevation = \ - check_real(location.async_get_elevation) util.get_local_ip = lambda: '127.0.0.1' @@ -102,11 +100,11 @@ def mock_device_tracker_conf(): devices.append(entity) with patch( - 'homeassistant.components.device_tracker' + 'homeassistant.components.device_tracker.legacy' '.DeviceTracker.async_update_config', side_effect=mock_update_config ), patch( - 'homeassistant.components.device_tracker.async_load_config', + 'homeassistant.components.device_tracker.legacy.async_load_config', side_effect=lambda *args: mock_coro(devices) ): yield devices diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 04c91cfdc08..eda62e1614c 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -6,7 +6,8 @@ import pytest from homeassistant import config_entries, data_entry_flow, setup from homeassistant.helpers import config_entry_flow from tests.common import ( - MockConfigEntry, MockModule, mock_coro, mock_integration) + MockConfigEntry, MockModule, mock_coro, mock_integration, + mock_entity_platform) @pytest.fixture @@ -74,24 +75,26 @@ async def test_user_has_confirmation(hass, discovery_flow_conf): assert result['type'] == data_entry_flow.RESULT_TYPE_FORM -async def test_discovery_single_instance(hass, discovery_flow_conf): - """Test we ask for confirmation via discovery.""" +@pytest.mark.parametrize('source', ['discovery', 'ssdp', 'zeroconf']) +async def test_discovery_single_instance(hass, discovery_flow_conf, source): + """Test we not allow duplicates.""" flow = config_entries.HANDLERS['test']() flow.hass = hass MockConfigEntry(domain='test').add_to_hass(hass) - result = await flow.async_step_discovery({}) + result = await getattr(flow, "async_step_{}".format(source))({}) assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT assert result['reason'] == 'single_instance_allowed' -async def test_discovery_confirmation(hass, discovery_flow_conf): +@pytest.mark.parametrize('source', ['discovery', 'ssdp', 'zeroconf']) +async def test_discovery_confirmation(hass, discovery_flow_conf, source): """Test we ask for confirmation via discovery.""" flow = config_entries.HANDLERS['test']() flow.hass = hass - result = await flow.async_step_discovery({}) + result = await getattr(flow, "async_step_{}".format(source))({}) assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['step_id'] == 'confirm' @@ -102,7 +105,7 @@ async def test_discovery_confirmation(hass, discovery_flow_conf): async def test_multiple_discoveries(hass, discovery_flow_conf): """Test we only create one instance for multiple discoveries.""" - mock_integration(hass, MockModule('test')) + mock_entity_platform(hass, 'config_flow.test', None) result = await hass.config_entries.flow.async_init( 'test', context={'source': config_entries.SOURCE_DISCOVERY}, data={}) @@ -116,7 +119,7 @@ async def test_multiple_discoveries(hass, discovery_flow_conf): async def test_only_one_in_progress(hass, discovery_flow_conf): """Test a user initialized one will finish and cancel discovered one.""" - mock_integration(hass, MockModule('test')) + mock_entity_platform(hass, 'config_flow.test', None) # Discovery starts flow result = await hass.config_entries.flow.async_init( @@ -209,6 +212,7 @@ async def test_webhook_create_cloudhook(hass, webhook_flow_conf): async_unload_entry=async_unload_entry, async_remove_entry=config_entry_flow.webhook_async_remove_entry, )) + mock_entity_platform(hass, 'config_flow.test_single', None) result = await hass.config_entries.flow.async_init( 'test_single', context={'source': config_entries.SOURCE_USER}) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 4b08bf960bf..444bd44133b 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -211,10 +211,10 @@ async def test_removing_config_entries(hass, registry, update_events): registry.async_clear_config_entry('123') entry = registry.async_get_device({('bridgeid', '0123')}, set()) - entry3 = registry.async_get_device({('bridgeid', '4567')}, set()) + entry3_removed = registry.async_get_device({('bridgeid', '4567')}, set()) assert entry.config_entries == {'456'} - assert entry3.config_entries == set() + assert entry3_removed is None await hass.async_block_till_done() @@ -227,7 +227,7 @@ async def test_removing_config_entries(hass, registry, update_events): assert update_events[2]['device_id'] == entry3.id assert update_events[3]['action'] == 'update' assert update_events[3]['device_id'] == entry.id - assert update_events[4]['action'] == 'update' + assert update_events[4]['action'] == 'remove' assert update_events[4]['device_id'] == entry3.id diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 65c22aa176f..95e1af403d4 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -209,7 +209,7 @@ async def test_platform_error_slow_setup(hass, caplog): async def setup_platform(*args): called.append(1) - await asyncio.sleep(1, loop=hass.loop) + await asyncio.sleep(1) platform = MockPlatform(async_setup_platform=setup_platform) component = EntityComponent(_LOGGER, DOMAIN, hass) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 624adbb8ea3..61d3af6e6f2 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -11,7 +11,7 @@ from homeassistant.helpers import entity_registry from tests.common import mock_registry, flush_store -YAML__OPEN_PATH = 'homeassistant.util.yaml.open' +YAML__OPEN_PATH = 'homeassistant.util.yaml.loader.open' @pytest.fixture @@ -213,15 +213,14 @@ async def test_removing_config_entry_id(hass, registry, update_events): assert entry.config_entry_id == 'mock-id-1' registry.async_clear_config_entry('mock-id-1') - entry = registry.entities[entry.entity_id] - assert entry.config_entry_id is None + assert not registry.entities await hass.async_block_till_done() assert len(update_events) == 2 assert update_events[0]['action'] == 'create' assert update_events[0]['entity_id'] == entry.entity_id - assert update_events[1]['action'] == 'update' + assert update_events[1]['action'] == 'remove' assert update_events[1]['entity_id'] == entry.entity_id diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index ebf883bfe12..de871c6f474 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -5,9 +5,9 @@ from unittest.mock import patch import pytest -from homeassistant import config_entries import homeassistant.helpers.translation as translation from homeassistant.setup import async_setup_component +from homeassistant.generated import config_flows from tests.common import mock_coro @@ -15,7 +15,7 @@ from tests.common import mock_coro def mock_config_flows(): """Mock the config flows.""" flows = [] - with patch.object(config_entries, 'FLOWS', flows): + with patch.object(config_flows, 'FLOWS', flows): yield flows diff --git a/tests/test_config.py b/tests/test_config.py index 9090e229248..29058f185ad 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,7 @@ """Test config utils.""" # pylint: disable=protected-access import asyncio +import copy import os import unittest.mock as mock from collections import OrderedDict @@ -11,16 +12,16 @@ import pytest from voluptuous import MultipleInvalid, Invalid import yaml -from homeassistant.core import DOMAIN, HomeAssistantError, Config +from homeassistant.core import SOURCE_STORAGE, HomeAssistantError import homeassistant.config as config_util from homeassistant.loader import async_get_integration from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ASSUMED_STATE, CONF_LATITUDE, CONF_LONGITUDE, CONF_UNIT_SYSTEM, CONF_NAME, - CONF_TIME_ZONE, CONF_ELEVATION, CONF_CUSTOMIZE, __version__, + CONF_CUSTOMIZE, __version__, CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, CONF_AUTH_PROVIDERS, CONF_AUTH_MFA_MODULES) -from homeassistant.util import location as location_util, dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.yaml import SECRET_YAML from homeassistant.helpers.entity import Entity from homeassistant.components.config.group import ( @@ -29,12 +30,10 @@ from homeassistant.components.config.automation import ( CONFIG_PATH as AUTOMATIONS_CONFIG_PATH) from homeassistant.components.config.script import ( CONFIG_PATH as SCRIPTS_CONFIG_PATH) -from homeassistant.components.config.customize import ( - CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) import homeassistant.scripts.check_config as check_config from tests.common import ( - get_test_config_dir, patch_yaml_files, mock_coro) + get_test_config_dir, patch_yaml_files) CONFIG_DIR = get_test_config_dir() YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) @@ -43,7 +42,6 @@ VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) GROUP_PATH = os.path.join(CONFIG_DIR, GROUP_CONFIG_PATH) AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, AUTOMATIONS_CONFIG_PATH) SCRIPTS_PATH = os.path.join(CONFIG_DIR, SCRIPTS_CONFIG_PATH) -CUSTOMIZE_PATH = os.path.join(CONFIG_DIR, CUSTOMIZE_CONFIG_PATH) ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE @@ -75,20 +73,16 @@ def teardown(): if os.path.isfile(SCRIPTS_PATH): os.remove(SCRIPTS_PATH) - if os.path.isfile(CUSTOMIZE_PATH): - os.remove(CUSTOMIZE_PATH) - async def test_create_default_config(hass): """Test creation of default config.""" - await config_util.async_create_default_config(hass, CONFIG_DIR, False) + await config_util.async_create_default_config(hass, CONFIG_DIR) assert os.path.isfile(YAML_PATH) assert os.path.isfile(SECRET_PATH) assert os.path.isfile(VERSION_PATH) assert os.path.isfile(GROUP_PATH) assert os.path.isfile(AUTOMATIONS_PATH) - assert os.path.isfile(CUSTOMIZE_PATH) def test_find_config_file_yaml(): @@ -104,7 +98,7 @@ async def test_ensure_config_exists_creates_config(hass): If not creates a new config file. """ with mock.patch('builtins.print') as mock_print: - await config_util.async_ensure_config_exists(hass, CONFIG_DIR, False) + await config_util.async_ensure_config_exists(hass, CONFIG_DIR) assert os.path.isfile(YAML_PATH) assert mock_print.called @@ -113,7 +107,7 @@ async def test_ensure_config_exists_creates_config(hass): async def test_ensure_config_exists_uses_existing_config(hass): """Test that calling ensure_config_exists uses existing config.""" create_file(YAML_PATH) - await config_util.async_ensure_config_exists(hass, CONFIG_DIR, False) + await config_util.async_ensure_config_exists(hass, CONFIG_DIR) with open(YAML_PATH) as f: content = f.read() @@ -166,38 +160,6 @@ def test_load_yaml_config_preserves_key_order(): list(config_util.load_yaml_config_file(YAML_PATH).items()) -async def test_create_default_config_detect_location(hass): - """Test that detect location sets the correct config keys.""" - with mock.patch('homeassistant.util.location.async_detect_location_info', - return_value=mock_coro(location_util.LocationInfo( - '0.0.0.0', 'US', 'United States', 'CA', 'California', - 'San Diego', '92122', 'America/Los_Angeles', 32.8594, - -117.2073, True))), \ - mock.patch('homeassistant.util.location.async_get_elevation', - return_value=mock_coro(101)), \ - mock.patch('builtins.print') as mock_print: - await config_util.async_ensure_config_exists(hass, CONFIG_DIR) - - config = config_util.load_yaml_config_file(YAML_PATH) - - assert DOMAIN in config - - ha_conf = config[DOMAIN] - - expected_values = { - CONF_LATITUDE: 32.8594, - CONF_LONGITUDE: -117.2073, - CONF_ELEVATION: 101, - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, - CONF_NAME: 'Home', - CONF_TIME_ZONE: 'America/Los_Angeles', - CONF_CUSTOMIZE: OrderedDict(), - } - - assert expected_values == ha_conf - assert mock_print.called - - async def test_create_default_config_returns_none_if_write_error(hass): """Test the writing of a default configuration. @@ -205,7 +167,7 @@ async def test_create_default_config_returns_none_if_write_error(hass): """ with mock.patch('builtins.print') as mock_print: assert await config_util.async_create_default_config( - hass, os.path.join(CONFIG_DIR, 'non_existing_dir/'), False) is None + hass, os.path.join(CONFIG_DIR, 'non_existing_dir/')) is None assert mock_print.called @@ -294,7 +256,8 @@ async def test_entity_customization(hass): @mock.patch('homeassistant.config.shutil') @mock.patch('homeassistant.config.os') -def test_remove_lib_on_upgrade(mock_os, mock_shutil, hass): +@mock.patch('homeassistant.config.is_docker_env', return_value=False) +def test_remove_lib_on_upgrade(mock_docker, mock_os, mock_shutil, hass): """Test removal of library on upgrade from before 0.50.""" ha_version = '0.49.0' mock_os.path.isdir = mock.Mock(return_value=True) @@ -313,6 +276,28 @@ def test_remove_lib_on_upgrade(mock_os, mock_shutil, hass): assert mock_shutil.rmtree.call_args == mock.call(hass_path) +@mock.patch('homeassistant.config.shutil') +@mock.patch('homeassistant.config.os') +@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' + mock_os.path.isdir = mock.Mock(return_value=True) + mock_open = mock.mock_open() + with mock.patch('homeassistant.config.open', mock_open, create=True): + opened_file = mock_open.return_value + # pylint: disable=no-member + opened_file.readline.return_value = ha_version + hass.config.path = mock.Mock() + config_util.process_ha_config_upgrade(hass) + hass_path = hass.config.path.return_value + + assert mock_os.path.isdir.call_count == 1 + assert mock_os.path.isdir.call_args == mock.call(hass_path) + assert mock_shutil.rmtree.call_count == 1 + assert mock_shutil.rmtree.call_args == mock.call(hass_path) + + def test_process_config_upgrade(hass): """Test update of version on upgrade.""" ha_version = '0.92.0' @@ -414,10 +399,91 @@ def test_migrate_no_file_on_upgrade(mock_os, mock_shutil, hass): assert mock_os.rename.call_count == 0 +async def test_loading_configuration_from_storage(hass, hass_storage): + """Test loading core config onto hass object.""" + hass_storage["core.config"] = { + 'data': { + 'elevation': 10, + 'latitude': 55, + 'location_name': 'Home', + 'longitude': 13, + 'time_zone': 'Europe/Copenhagen', + 'unit_system': 'metric' + }, + 'key': 'core.config', + 'version': 1 + } + await config_util.async_process_ha_core_config( + hass, {'whitelist_external_dirs': '/tmp'}) + + assert hass.config.latitude == 55 + assert hass.config.longitude == 13 + assert hass.config.elevation == 10 + assert hass.config.location_name == 'Home' + assert hass.config.units.name == CONF_UNIT_SYSTEM_METRIC + assert hass.config.time_zone.zone == 'Europe/Copenhagen' + assert len(hass.config.whitelist_external_dirs) == 2 + assert '/tmp' in hass.config.whitelist_external_dirs + assert hass.config.config_source == SOURCE_STORAGE + + +async def test_updating_configuration(hass, hass_storage): + """Test updating configuration stores the new configuration.""" + core_data = { + 'data': { + 'elevation': 10, + 'latitude': 55, + 'location_name': 'Home', + 'longitude': 13, + 'time_zone': 'Europe/Copenhagen', + 'unit_system': 'metric' + }, + 'key': 'core.config', + 'version': 1 + } + hass_storage["core.config"] = dict(core_data) + await config_util.async_process_ha_core_config( + hass, {'whitelist_external_dirs': '/tmp'}) + await hass.config.update(latitude=50) + + new_core_data = copy.deepcopy(core_data) + new_core_data['data']['latitude'] = 50 + assert hass_storage["core.config"] == new_core_data + assert hass.config.latitude == 50 + + +async def test_override_stored_configuration(hass, hass_storage): + """Test loading core and YAML config onto hass object.""" + hass_storage["core.config"] = { + 'data': { + 'elevation': 10, + 'latitude': 55, + 'location_name': 'Home', + 'longitude': 13, + 'time_zone': 'Europe/Copenhagen', + 'unit_system': 'metric' + }, + 'key': 'core.config', + 'version': 1 + } + await config_util.async_process_ha_core_config(hass, { + 'latitude': 60, + 'whitelist_external_dirs': '/tmp', + }) + + assert hass.config.latitude == 60 + assert hass.config.longitude == 13 + assert hass.config.elevation == 10 + assert hass.config.location_name == 'Home' + assert hass.config.units.name == CONF_UNIT_SYSTEM_METRIC + assert hass.config.time_zone.zone == 'Europe/Copenhagen' + assert len(hass.config.whitelist_external_dirs) == 2 + assert '/tmp' in hass.config.whitelist_external_dirs + assert hass.config.config_source == config_util.SOURCE_YAML + + async def test_loading_configuration(hass): """Test loading core config onto hass object.""" - hass.config = mock.Mock() - await config_util.async_process_ha_core_config(hass, { 'latitude': 60, 'longitude': 50, @@ -436,12 +502,11 @@ async def test_loading_configuration(hass): assert hass.config.time_zone.zone == 'America/New_York' assert len(hass.config.whitelist_external_dirs) == 2 assert '/tmp' in hass.config.whitelist_external_dirs + assert hass.config.config_source == config_util.SOURCE_YAML async def test_loading_configuration_temperature_unit(hass): """Test backward compatibility when loading core config.""" - hass.config = mock.Mock() - await config_util.async_process_ha_core_config(hass, { 'latitude': 60, 'longitude': 50, @@ -457,12 +522,11 @@ async def test_loading_configuration_temperature_unit(hass): assert hass.config.location_name == 'Huis' assert hass.config.units.name == CONF_UNIT_SYSTEM_METRIC assert hass.config.time_zone.zone == 'America/New_York' + assert hass.config.config_source == config_util.SOURCE_YAML async def test_loading_configuration_from_packages(hass): """Test loading packages config onto hass object config.""" - hass.config = mock.Mock() - await config_util.async_process_ha_core_config(hass, { 'latitude': 39, 'longitude': -1, @@ -490,57 +554,6 @@ async def test_loading_configuration_from_packages(hass): }) -@asynctest.mock.patch( - 'homeassistant.util.location.async_detect_location_info', - autospec=True, return_value=mock_coro(location_util.LocationInfo( - '0.0.0.0', 'US', 'United States', 'CA', - 'California', 'San Diego', '92122', - 'America/Los_Angeles', 32.8594, -117.2073, True))) -@asynctest.mock.patch('homeassistant.util.location.async_get_elevation', - autospec=True, return_value=mock_coro(101)) -async def test_discovering_configuration(mock_detect, mock_elevation, hass): - """Test auto discovery for missing core configs.""" - hass.config.latitude = None - hass.config.longitude = None - hass.config.elevation = None - hass.config.location_name = None - hass.config.time_zone = None - - await config_util.async_process_ha_core_config(hass, {}) - - assert hass.config.latitude == 32.8594 - assert hass.config.longitude == -117.2073 - assert hass.config.elevation == 101 - assert hass.config.location_name == 'San Diego' - assert hass.config.units.name == CONF_UNIT_SYSTEM_METRIC - assert hass.config.units.is_metric - assert hass.config.time_zone.zone == 'America/Los_Angeles' - - -@asynctest.mock.patch('homeassistant.util.location.async_detect_location_info', - autospec=True, return_value=mock_coro(None)) -@asynctest.mock.patch('homeassistant.util.location.async_get_elevation', - return_value=mock_coro(0)) -async def test_discovering_configuration_auto_detect_fails(mock_detect, - mock_elevation, - hass): - """Test config remains unchanged if discovery fails.""" - hass.config = Config() - hass.config.config_dir = "/test/config" - - await config_util.async_process_ha_core_config(hass, {}) - - blankConfig = Config() - assert hass.config.latitude == blankConfig.latitude - assert hass.config.longitude == blankConfig.longitude - assert hass.config.elevation == blankConfig.elevation - assert hass.config.location_name == blankConfig.location_name - assert hass.config.units == blankConfig.units - assert hass.config.time_zone == blankConfig.time_zone - assert len(hass.config.whitelist_external_dirs) == 1 - assert "/test/config/www" in hass.config.whitelist_external_dirs - - @asynctest.mock.patch( 'homeassistant.scripts.check_config.check_ha_config_file') async def test_check_ha_config_file_correct(mock_check, hass): diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index a8a1211f4c2..752cb5eb277 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -53,6 +53,7 @@ async def test_call_setup_entry(hass): hass, MockModule('comp', async_setup_entry=mock_setup_entry, async_migrate_entry=mock_migrate_entry)) + mock_entity_platform(hass, 'config_flow.comp', None) result = await async_setup_component(hass, 'comp', {}) assert result @@ -74,6 +75,7 @@ async def test_call_async_migrate_entry(hass): hass, MockModule('comp', async_setup_entry=mock_setup_entry, async_migrate_entry=mock_migrate_entry)) + mock_entity_platform(hass, 'config_flow.comp', None) result = await async_setup_component(hass, 'comp', {}) assert result @@ -95,6 +97,7 @@ async def test_call_async_migrate_entry_failure_false(hass): hass, MockModule('comp', async_setup_entry=mock_setup_entry, async_migrate_entry=mock_migrate_entry)) + mock_entity_platform(hass, 'config_flow.comp', None) result = await async_setup_component(hass, 'comp', {}) assert result @@ -117,6 +120,7 @@ async def test_call_async_migrate_entry_failure_exception(hass): hass, MockModule('comp', async_setup_entry=mock_setup_entry, async_migrate_entry=mock_migrate_entry)) + mock_entity_platform(hass, 'config_flow.comp', None) result = await async_setup_component(hass, 'comp', {}) assert result @@ -139,6 +143,7 @@ async def test_call_async_migrate_entry_failure_not_bool(hass): hass, MockModule('comp', async_setup_entry=mock_setup_entry, async_migrate_entry=mock_migrate_entry)) + mock_entity_platform(hass, 'config_flow.comp', None) result = await async_setup_component(hass, 'comp', {}) assert result @@ -158,6 +163,7 @@ async def test_call_async_migrate_entry_failure_not_supported(hass): mock_integration( hass, MockModule('comp', async_setup_entry=mock_setup_entry)) + mock_entity_platform(hass, 'config_flow.comp', None) result = await async_setup_component(hass, 'comp', {}) assert result @@ -201,6 +207,7 @@ async def test_remove_entry(hass, manager): mock_entity_platform( hass, 'light.test', MockPlatform(async_setup_entry=mock_setup_entry_platform)) + mock_entity_platform(hass, 'config_flow.test', None) MockConfigEntry( domain='test_other', entry_id='test1' @@ -254,9 +261,9 @@ async def test_remove_entry(hass, manager): # Just Group all_lights assert len(hass.states.async_all()) == 1 - # Check that entity registry entry no longer references config_entry_id - entity_entry = list(ent_reg.entities.values())[0] - assert entity_entry.config_entry_id is None + # Check that entity registry entry has been removed + entity_entry_list = list(ent_reg.entities.values()) + assert not entity_entry_list async def test_remove_entry_handles_callback_error(hass, manager): @@ -361,6 +368,7 @@ def test_add_entry_calls_setup_entry(hass, manager): mock_integration( hass, MockModule('comp', async_setup_entry=mock_setup_entry)) + mock_entity_platform(hass, 'config_flow.comp', None) class TestFlow(config_entries.ConfigFlow): @@ -416,6 +424,7 @@ async def test_saving_and_loading(hass): """Test that we're saving and loading correctly.""" mock_integration(hass, MockModule( 'test', async_setup_entry=lambda *args: mock_coro(True))) + mock_entity_platform(hass, 'config_flow.test', None) class TestFlow(config_entries.ConfigFlow): VERSION = 5 @@ -511,6 +520,7 @@ async def test_forward_entry_does_not_setup_entry_if_setup_fails(hass): async def test_discovery_notification(hass): """Test that we create/dismiss a notification when source is discovery.""" mock_integration(hass, MockModule('test')) + mock_entity_platform(hass, 'config_flow.test', None) await async_setup_component(hass, 'persistent_notification', {}) class TestFlow(config_entries.ConfigFlow): @@ -548,6 +558,7 @@ async def test_discovery_notification(hass): async def test_discovery_notification_not_created(hass): """Test that we not create a notification when discovery is aborted.""" mock_integration(hass, MockModule('test')) + mock_entity_platform(hass, 'config_flow.test', None) await async_setup_component(hass, 'persistent_notification', {}) class TestFlow(config_entries.ConfigFlow): @@ -629,6 +640,7 @@ async def test_setup_raise_not_ready(hass, caplog): mock_setup_entry = MagicMock(side_effect=ConfigEntryNotReady) mock_integration( hass, MockModule('test', async_setup_entry=mock_setup_entry)) + mock_entity_platform(hass, 'config_flow.test', None) with patch('homeassistant.helpers.event.async_call_later') as mock_call: await entry.async_setup(hass) @@ -655,6 +667,7 @@ async def test_setup_retrying_during_unload(hass): mock_setup_entry = MagicMock(side_effect=ConfigEntryNotReady) mock_integration( hass, MockModule('test', async_setup_entry=mock_setup_entry)) + mock_entity_platform(hass, 'config_flow.test', None) with patch('homeassistant.helpers.event.async_call_later') as mock_call: await entry.async_setup(hass) @@ -720,6 +733,7 @@ async def test_entry_setup_succeed(hass, manager): async_setup=mock_setup, async_setup_entry=mock_setup_entry )) + mock_entity_platform(hass, 'config_flow.comp', None) assert await manager.async_setup(entry.entry_id) assert len(mock_setup.mock_calls) == 1 @@ -848,6 +862,7 @@ async def test_entry_reload_succeed(hass, manager): async_setup_entry=async_setup_entry, async_unload_entry=async_unload_entry )) + mock_entity_platform(hass, 'config_flow.comp', None) assert await manager.async_reload(entry.entry_id) assert len(async_unload_entry.mock_calls) == 1 @@ -879,6 +894,7 @@ async def test_entry_reload_not_loaded(hass, manager, state): async_setup_entry=async_setup_entry, async_unload_entry=async_unload_entry )) + mock_entity_platform(hass, 'config_flow.comp', None) assert await manager.async_reload(entry.entry_id) assert len(async_unload_entry.mock_calls) == 0 diff --git a/tests/test_core.py b/tests/test_core.py index 1e709ed3a8a..15ab2baf3a9 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -23,7 +23,8 @@ from homeassistant.const import ( __version__, EVENT_STATE_CHANGED, ATTR_FRIENDLY_NAME, CONF_UNIT_SYSTEM, ATTR_NOW, EVENT_TIME_CHANGED, EVENT_TIMER_OUT_OF_SYNC, ATTR_SECONDS, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE, - EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, EVENT_CALL_SERVICE) + EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, EVENT_CALL_SERVICE, + EVENT_CORE_CONFIG_UPDATE) from tests.common import get_test_home_assistant, async_mock_service @@ -183,7 +184,7 @@ class TestHomeAssistant(unittest.TestCase): self.hass.add_job(test_coro()) run_coroutine_threadsafe( - asyncio.wait(self.hass._pending_tasks, loop=self.hass.loop), + asyncio.wait(self.hass._pending_tasks), loop=self.hass.loop ).result() @@ -205,8 +206,8 @@ class TestHomeAssistant(unittest.TestCase): @asyncio.coroutine def wait_finish_callback(): """Wait until all stuff is scheduled.""" - yield from asyncio.sleep(0, loop=self.hass.loop) - yield from asyncio.sleep(0, loop=self.hass.loop) + yield from asyncio.sleep(0) + yield from asyncio.sleep(0) run_coroutine_threadsafe( wait_finish_callback(), self.hass.loop).result() @@ -226,8 +227,8 @@ class TestHomeAssistant(unittest.TestCase): @asyncio.coroutine def wait_finish_callback(): """Wait until all stuff is scheduled.""" - yield from asyncio.sleep(0, loop=self.hass.loop) - yield from asyncio.sleep(0, loop=self.hass.loop) + yield from asyncio.sleep(0) + yield from asyncio.sleep(0) for _ in range(2): self.hass.add_job(test_executor) @@ -251,8 +252,8 @@ class TestHomeAssistant(unittest.TestCase): @asyncio.coroutine def wait_finish_callback(): """Wait until all stuff is scheduled.""" - yield from asyncio.sleep(0, loop=self.hass.loop) - yield from asyncio.sleep(0, loop=self.hass.loop) + yield from asyncio.sleep(0) + yield from asyncio.sleep(0) for _ in range(2): self.hass.add_job(test_callback) @@ -871,7 +872,7 @@ class TestConfig(unittest.TestCase): # pylint: disable=invalid-name def setUp(self): """Set up things to be run when tests are started.""" - self.config = ha.Config() + self.config = ha.Config(None) assert self.config.config_dir is None def test_path_with_file(self): @@ -890,16 +891,17 @@ class TestConfig(unittest.TestCase): """Test as dict.""" self.config.config_dir = '/tmp/ha-config' expected = { - 'latitude': None, - 'longitude': None, - 'elevation': None, + 'latitude': 0, + 'longitude': 0, + 'elevation': 0, CONF_UNIT_SYSTEM: METRIC_SYSTEM.as_dict(), - 'location_name': None, + 'location_name': "Home", 'time_zone': 'UTC', 'components': set(), 'config_dir': '/tmp/ha-config', 'whitelist_external_dirs': set(), 'version': __version__, + 'config_source': "default", } assert expected == self.config.as_dict() @@ -941,6 +943,32 @@ class TestConfig(unittest.TestCase): self.config.is_allowed_path(None) +async def test_event_on_update(hass, hass_storage): + """Test that event is fired on update.""" + events = [] + + @ha.callback + def callback(event): + events.append(event) + + hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, callback) + + assert hass.config.latitude != 12 + + await hass.config.update(latitude=12) + await hass.async_block_till_done() + + assert hass.config.latitude == 12 + assert len(events) == 1 + assert events[0].data == {'latitude': 12} + + +def test_bad_timezone_raises_value_error(hass): + """Test bad timezone raises ValueError.""" + with pytest.raises(ValueError): + hass.config.set_time_zone('not_a_timezone') + + @patch('homeassistant.core.monotonic') def test_create_timer(mock_monotonic, loop): """Test create timer.""" diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index aa8240ff567..379ab35cad2 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -5,6 +5,8 @@ import voluptuous as vol from homeassistant import data_entry_flow from homeassistant.util.decorator import Registry +from tests.common import async_capture_events + @pytest.fixture def manager(): @@ -19,16 +21,13 @@ def manager(): raise data_entry_flow.UnknownHandler flow = handler() - flow.init_step = context.get('init_step', 'init') \ - if context is not None else 'init' - flow.source = context.get('source') \ - if context is not None else 'user_input' + flow.init_step = context.get('init_step', 'init') + flow.source = context.get('source') return flow async def async_add_entry(flow, result): if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: - result['source'] = flow.context.get('source') \ - if flow.context is not None else 'user' + result['source'] = flow.context.get('source') entries.append(result) return result @@ -171,7 +170,7 @@ async def test_create_saves_data(manager): assert entry['handler'] == 'test' assert entry['title'] == 'Test Title' assert entry['data'] == 'Test Data' - assert entry['source'] == 'user' + assert entry['source'] is None async def test_discovery_init_flow(manager): @@ -245,3 +244,57 @@ async def test_finish_callback_change_result_type(hass): result = await manager.async_configure(result['flow_id'], {'count': 2}) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result['result'] == 2 + + +async def test_external_step(hass, manager): + """Test external step logic.""" + manager.hass = hass + + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + data = None + + async def async_step_init(self, user_input=None): + if not user_input: + return self.async_external_step( + step_id='init', + url='https://example.com', + ) + + self.data = user_input + return self.async_external_step_done(next_step_id='finish') + + async def async_step_finish(self, user_input=None): + return self.async_create_entry( + title=self.data['title'], + data=self.data + ) + + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESSED + ) + + result = await manager.async_init('test') + assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert len(manager.async_progress()) == 1 + + # Mimic external step + # Called by integrations: `hass.config_entries.flow.async_configure(…)` + result = await manager.async_configure(result['flow_id'], { + 'title': 'Hello' + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP_DONE + + await hass.async_block_till_done() + assert len(events) == 1 + assert events[0].data == { + 'handler': 'test', + 'flow_id': result['flow_id'], + 'refresh': True + } + + # Frontend refreshses the flow + result = await manager.async_configure(result['flow_id']) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == "Hello" diff --git a/tests/test_requirements.py b/tests/test_requirements.py index dcc107ea07e..fc9dee20ed2 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -1,24 +1,15 @@ """Test requirements module.""" import os +from pathlib import Path from unittest.mock import patch, call from homeassistant import setup from homeassistant.requirements import ( - CONSTRAINT_FILE, PackageLoadable, async_process_requirements) - -import pkg_resources + CONSTRAINT_FILE, async_process_requirements, PROGRESS_FILE, _install) from tests.common import ( get_test_home_assistant, MockModule, mock_coro, mock_integration) -RESOURCE_DIR = os.path.abspath( - os.path.join(os.path.dirname(__file__), '..', 'resources')) - -TEST_NEW_REQ = 'pyhelloworld3==1.0.0' - -TEST_ZIP_REQ = 'file://{}#{}' \ - .format(os.path.join(RESOURCE_DIR, 'pyhelloworld3.zip'), TEST_NEW_REQ) - class TestRequirements: """Test the requirements module.""" @@ -37,11 +28,11 @@ class TestRequirements: @patch('os.path.dirname') @patch('homeassistant.util.package.is_virtual_env', return_value=True) + @patch('homeassistant.util.package.is_docker_env', return_value=False) @patch('homeassistant.util.package.install_package', return_value=True) def test_requirement_installed_in_venv( - self, mock_install, mock_venv, mock_dirname): + self, mock_install, mock_denv, mock_venv, mock_dirname): """Test requirement installed in virtual environment.""" - mock_venv.return_value = True mock_dirname.return_value = 'ha_package_path' self.hass.config.skip_pip = False mock_integration( @@ -51,13 +42,16 @@ class TestRequirements: assert 'comp' in self.hass.config.components assert mock_install.call_args == call( 'package==0.0.1', - constraints=os.path.join('ha_package_path', CONSTRAINT_FILE)) + constraints=os.path.join('ha_package_path', CONSTRAINT_FILE), + no_cache_dir=False, + ) @patch('os.path.dirname') @patch('homeassistant.util.package.is_virtual_env', return_value=False) + @patch('homeassistant.util.package.is_docker_env', return_value=False) @patch('homeassistant.util.package.install_package', return_value=True) def test_requirement_installed_in_deps( - self, mock_install, mock_venv, mock_dirname): + self, mock_install, mock_denv, mock_venv, mock_dirname): """Test requirement installed in deps directory.""" mock_dirname.return_value = 'ha_package_path' self.hass.config.skip_pip = False @@ -68,7 +62,9 @@ class TestRequirements: assert 'comp' in self.hass.config.components assert mock_install.call_args == call( 'package==0.0.1', target=self.hass.config.path('deps'), - constraints=os.path.join('ha_package_path', CONSTRAINT_FILE)) + constraints=os.path.join('ha_package_path', CONSTRAINT_FILE), + no_cache_dir=False, + ) async def test_install_existing_package(hass): @@ -80,8 +76,7 @@ async def test_install_existing_package(hass): assert len(mock_inst.mock_calls) == 1 - with patch('homeassistant.requirements.PackageLoadable.loadable', - return_value=mock_coro(True)), \ + with patch('homeassistant.util.package.is_installed', return_value=True), \ patch( 'homeassistant.util.package.install_package') as mock_inst: assert await async_process_requirements( @@ -90,37 +85,81 @@ async def test_install_existing_package(hass): assert len(mock_inst.mock_calls) == 0 -async def test_check_package_global(hass): - """Test for an installed package.""" - installed_package = list(pkg_resources.working_set)[0].project_name - assert await PackageLoadable(hass).loadable(installed_package) +async def test_install_with_wheels_index(hass): + """Test an install attempt with wheels index URL.""" + hass.config.skip_pip = False + mock_integration( + hass, MockModule('comp', requirements=['hello==1.0.0'])) + + with patch( + 'homeassistant.util.package.is_installed', return_value=False + ), \ + patch( + 'homeassistant.util.package.is_docker_env', return_value=True + ), \ + patch( + 'homeassistant.util.package.install_package' + ) as mock_inst, \ + patch.dict( + os.environ, {'WHEELS_LINKS': "https://wheels.hass.io/test"} + ), \ + patch( + 'os.path.dirname' + ) as mock_dir: + mock_dir.return_value = 'ha_package_path' + assert await setup.async_setup_component(hass, 'comp', {}) + assert 'comp' in hass.config.components + print(mock_inst.call_args) + assert mock_inst.call_args == call( + 'hello==1.0.0', find_links="https://wheels.hass.io/test", + constraints=os.path.join('ha_package_path', CONSTRAINT_FILE), + no_cache_dir=True, + ) -async def test_check_package_zip(hass): - """Test for an installed zip package.""" - assert not await PackageLoadable(hass).loadable(TEST_ZIP_REQ) +async def test_install_on_docker(hass): + """Test an install attempt on an docker system env.""" + hass.config.skip_pip = False + mock_integration( + hass, MockModule('comp', requirements=['hello==1.0.0'])) + + with patch( + 'homeassistant.util.package.is_installed', return_value=False + ), \ + patch( + 'homeassistant.util.package.is_docker_env', return_value=True + ), \ + patch( + 'homeassistant.util.package.install_package' + ) as mock_inst, \ + patch( + 'os.path.dirname' + ) as mock_dir: + mock_dir.return_value = 'ha_package_path' + assert await setup.async_setup_component(hass, 'comp', {}) + assert 'comp' in hass.config.components + print(mock_inst.call_args) + assert mock_inst.call_args == call( + 'hello==1.0.0', + constraints=os.path.join('ha_package_path', CONSTRAINT_FILE), + no_cache_dir=True, + ) -async def test_package_loadable_installed_twice(hass): - """Test that a package is loadable when installed twice. +async def test_progress_lock(hass): + """Test an install attempt on an existing package.""" + progress_path = Path(hass.config.path(PROGRESS_FILE)) + kwargs = {'hello': 'world'} - If a package is installed twice, only the first version will be imported. - Test that package_loadable will only compare with the first package. - """ - v1 = pkg_resources.Distribution(project_name='hello', version='1.0.0') - v2 = pkg_resources.Distribution(project_name='hello', version='2.0.0') + def assert_env(req, **passed_kwargs): + """Assert the env.""" + assert progress_path.exists() + assert req == 'hello' + assert passed_kwargs == kwargs + return True - with patch('pkg_resources.find_distributions', side_effect=[[v1]]): - assert not await PackageLoadable(hass).loadable('hello==2.0.0') + with patch('homeassistant.util.package.install_package', + side_effect=assert_env): + _install(hass, 'hello', kwargs) - with patch('pkg_resources.find_distributions', side_effect=[[v1], [v2]]): - assert not await PackageLoadable(hass).loadable('hello==2.0.0') - - with patch('pkg_resources.find_distributions', side_effect=[[v2], [v1]]): - assert await PackageLoadable(hass).loadable('hello==2.0.0') - - with patch('pkg_resources.find_distributions', side_effect=[[v2]]): - assert await PackageLoadable(hass).loadable('hello==2.0.0') - - with patch('pkg_resources.find_distributions', side_effect=[[v2]]): - assert await PackageLoadable(hass).loadable('Hello==2.0.0') + assert not progress_path.exists() diff --git a/tests/util/test_location.py b/tests/util/test_location.py index 2db37c46730..3fb7d07c2bb 100644 --- a/tests/util/test_location.py +++ b/tests/util/test_location.py @@ -116,10 +116,8 @@ async def test_detect_location_info_ip_api(aioclient_mock, session): async def test_detect_location_info_both_queries_fail(session): """Ensure we return None if both queries fail.""" - with patch('homeassistant.util.location.async_get_elevation', - return_value=mock_coro(0)), \ - patch('homeassistant.util.location._get_ipapi', - return_value=mock_coro(None)), \ + with patch('homeassistant.util.location._get_ipapi', + return_value=mock_coro(None)), \ patch('homeassistant.util.location._get_ip_api', return_value=mock_coro(None)): info = await location_util.async_detect_location_info( @@ -137,26 +135,3 @@ async def test_ip_api_query_raises(raising_session): """Test ip api query when the request to API fails.""" info = await location_util._get_ip_api(raising_session) assert info is None - - -async def test_elevation_query_raises(raising_session): - """Test elevation when the request to API fails.""" - elevation = await location_util.async_get_elevation( - raising_session, 10, 10, _test_real=True) - assert elevation == 0 - - -async def test_elevation_query_fails(aioclient_mock, session): - """Test elevation when the request to API fails.""" - aioclient_mock.get(location_util.ELEVATION_URL, text='{}', status=401) - elevation = await location_util.async_get_elevation( - session, 10, 10, _test_real=True) - assert elevation == 0 - - -async def test_elevation_query_nonjson(aioclient_mock, session): - """Test if elevation API returns a non JSON value.""" - aioclient_mock.get(location_util.ELEVATION_URL, text='{ I am not JSON }') - elevation = await location_util.async_get_elevation( - session, 10, 10, _test_real=True) - assert elevation == 0 diff --git a/tests/util/test_package.py b/tests/util/test_package.py index 5422140c232..3751c056907 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -6,13 +6,20 @@ import sys from subprocess import PIPE from unittest.mock import MagicMock, call, patch +import pkg_resources import pytest import homeassistant.util.package as package +RESOURCE_DIR = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'resources')) + TEST_NEW_REQ = 'pyhelloworld3==1.0.0' +TEST_ZIP_REQ = 'file://{}#{}' \ + .format(os.path.join(RESOURCE_DIR, 'pyhelloworld3.zip'), TEST_NEW_REQ) + @pytest.fixture def mock_sys(): @@ -160,6 +167,23 @@ def test_install_constraint(mock_sys, mock_popen, mock_env_copy, mock_venv): assert mock_popen.return_value.communicate.call_count == 1 +def test_install_find_links(mock_sys, mock_popen, mock_env_copy, mock_venv): + """Test install with find-links on not installed package.""" + env = mock_env_copy() + link = 'https://wheels-repository' + assert package.install_package( + TEST_NEW_REQ, False, find_links=link) + assert mock_popen.call_count == 1 + assert ( + mock_popen.call_args == + call([ + mock_sys.executable, '-m', 'pip', 'install', '--quiet', + TEST_NEW_REQ, '--find-links', link + ], stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env) + ) + assert mock_popen.return_value.communicate.call_count == 1 + + @asyncio.coroutine def test_async_get_user_site(mock_env_copy): """Test async get user site directory.""" @@ -176,3 +200,14 @@ def test_async_get_user_site(mock_env_copy): stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, env=env) assert ret == os.path.join(deps_dir, 'lib_dir') + + +def test_check_package_global(): + """Test for an installed package.""" + installed_package = list(pkg_resources.working_set)[0].project_name + assert package.is_installed(installed_package) + + +def test_check_package_zip(): + """Test for an installed zip package.""" + assert not package.is_installed(TEST_ZIP_REQ) diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index c7d1be3d58c..01a64f17b86 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -8,7 +8,8 @@ from unittest.mock import patch import pytest from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import yaml +from homeassistant.util.yaml import loader as yaml_loader +import homeassistant.util.yaml as yaml from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file from tests.common import get_test_config_dir, patch_yaml_files @@ -16,7 +17,7 @@ from tests.common import get_test_config_dir, patch_yaml_files @pytest.fixture(autouse=True) def mock_credstash(): """Mock credstash so it doesn't connect to the internet.""" - with patch.object(yaml, 'credstash') as mock_credstash: + with patch.object(yaml_loader, 'credstash') as mock_credstash: mock_credstash.getSecret.return_value = None yield mock_credstash @@ -25,7 +26,7 @@ def test_simple_list(): """Test simple list.""" conf = "config:\n - simple\n - list" with io.StringIO(conf) as file: - doc = yaml.yaml.safe_load(file) + doc = yaml_loader.yaml.safe_load(file) assert doc['config'] == ["simple", "list"] @@ -33,7 +34,7 @@ def test_simple_dict(): """Test simple dict.""" conf = "key: value" with io.StringIO(conf) as file: - doc = yaml.yaml.safe_load(file) + doc = yaml_loader.yaml.safe_load(file) assert doc['key'] == 'value' @@ -58,7 +59,7 @@ def test_environment_variable(): os.environ["PASSWORD"] = "secret_password" conf = "password: !env_var PASSWORD" with io.StringIO(conf) as file: - doc = yaml.yaml.safe_load(file) + doc = yaml_loader.yaml.safe_load(file) assert doc['password'] == "secret_password" del os.environ["PASSWORD"] @@ -67,7 +68,7 @@ def test_environment_variable_default(): """Test config file with default value for environment variable.""" conf = "password: !env_var PASSWORD secret_password" with io.StringIO(conf) as file: - doc = yaml.yaml.safe_load(file) + doc = yaml_loader.yaml.safe_load(file) assert doc['password'] == "secret_password" @@ -76,7 +77,7 @@ def test_invalid_environment_variable(): conf = "password: !env_var PASSWORD" with pytest.raises(HomeAssistantError): with io.StringIO(conf) as file: - yaml.yaml.safe_load(file) + yaml_loader.yaml.safe_load(file) def test_include_yaml(): @@ -84,17 +85,17 @@ def test_include_yaml(): with patch_yaml_files({'test.yaml': 'value'}): conf = 'key: !include test.yaml' with io.StringIO(conf) as file: - doc = yaml.yaml.safe_load(file) + doc = yaml_loader.yaml.safe_load(file) assert doc["key"] == "value" with patch_yaml_files({'test.yaml': None}): conf = 'key: !include test.yaml' with io.StringIO(conf) as file: - doc = yaml.yaml.safe_load(file) + doc = yaml_loader.yaml.safe_load(file) assert doc["key"] == {} -@patch('homeassistant.util.yaml.os.walk') +@patch('homeassistant.util.yaml.loader.os.walk') def test_include_dir_list(mock_walk): """Test include dir list yaml.""" mock_walk.return_value = [ @@ -107,11 +108,11 @@ def test_include_dir_list(mock_walk): }): conf = "key: !include_dir_list /tmp" with io.StringIO(conf) as file: - doc = yaml.yaml.safe_load(file) + doc = yaml_loader.yaml.safe_load(file) assert doc["key"] == sorted(["one", "two"]) -@patch('homeassistant.util.yaml.os.walk') +@patch('homeassistant.util.yaml.loader.os.walk') def test_include_dir_list_recursive(mock_walk): """Test include dir recursive list yaml.""" mock_walk.return_value = [ @@ -129,13 +130,13 @@ def test_include_dir_list_recursive(mock_walk): with io.StringIO(conf) as file: assert '.ignore' in mock_walk.return_value[0][1], \ "Expecting .ignore in here" - doc = yaml.yaml.safe_load(file) + doc = yaml_loader.yaml.safe_load(file) assert 'tmp2' in mock_walk.return_value[0][1] assert '.ignore' not in mock_walk.return_value[0][1] assert sorted(doc["key"]) == sorted(["zero", "one", "two"]) -@patch('homeassistant.util.yaml.os.walk') +@patch('homeassistant.util.yaml.loader.os.walk') def test_include_dir_named(mock_walk): """Test include dir named yaml.""" mock_walk.return_value = [ @@ -149,11 +150,11 @@ def test_include_dir_named(mock_walk): conf = "key: !include_dir_named /tmp" correct = {'first': 'one', 'second': 'two'} with io.StringIO(conf) as file: - doc = yaml.yaml.safe_load(file) + doc = yaml_loader.yaml.safe_load(file) assert doc["key"] == correct -@patch('homeassistant.util.yaml.os.walk') +@patch('homeassistant.util.yaml.loader.os.walk') def test_include_dir_named_recursive(mock_walk): """Test include dir named yaml.""" mock_walk.return_value = [ @@ -172,13 +173,13 @@ def test_include_dir_named_recursive(mock_walk): with io.StringIO(conf) as file: assert '.ignore' in mock_walk.return_value[0][1], \ "Expecting .ignore in here" - doc = yaml.yaml.safe_load(file) + doc = yaml_loader.yaml.safe_load(file) assert 'tmp2' in mock_walk.return_value[0][1] assert '.ignore' not in mock_walk.return_value[0][1] assert doc["key"] == correct -@patch('homeassistant.util.yaml.os.walk') +@patch('homeassistant.util.yaml.loader.os.walk') def test_include_dir_merge_list(mock_walk): """Test include dir merge list yaml.""" mock_walk.return_value = [['/tmp', [], ['first.yaml', 'second.yaml']]] @@ -189,11 +190,11 @@ def test_include_dir_merge_list(mock_walk): }): conf = "key: !include_dir_merge_list /tmp" with io.StringIO(conf) as file: - doc = yaml.yaml.safe_load(file) + doc = yaml_loader.yaml.safe_load(file) assert sorted(doc["key"]) == sorted(["one", "two", "three"]) -@patch('homeassistant.util.yaml.os.walk') +@patch('homeassistant.util.yaml.loader.os.walk') def test_include_dir_merge_list_recursive(mock_walk): """Test include dir merge list yaml.""" mock_walk.return_value = [ @@ -211,14 +212,14 @@ def test_include_dir_merge_list_recursive(mock_walk): with io.StringIO(conf) as file: assert '.ignore' in mock_walk.return_value[0][1], \ "Expecting .ignore in here" - doc = yaml.yaml.safe_load(file) + doc = yaml_loader.yaml.safe_load(file) assert 'tmp2' in mock_walk.return_value[0][1] assert '.ignore' not in mock_walk.return_value[0][1] assert sorted(doc["key"]) == sorted(["one", "two", "three", "four"]) -@patch('homeassistant.util.yaml.os.walk') +@patch('homeassistant.util.yaml.loader.os.walk') def test_include_dir_merge_named(mock_walk): """Test include dir merge named yaml.""" mock_walk.return_value = [['/tmp', [], ['first.yaml', 'second.yaml']]] @@ -231,7 +232,7 @@ def test_include_dir_merge_named(mock_walk): with patch_yaml_files(files): conf = "key: !include_dir_merge_named /tmp" with io.StringIO(conf) as file: - doc = yaml.yaml.safe_load(file) + doc = yaml_loader.yaml.safe_load(file) assert doc["key"] == { "key1": "one", "key2": "two", @@ -239,7 +240,7 @@ def test_include_dir_merge_named(mock_walk): } -@patch('homeassistant.util.yaml.os.walk') +@patch('homeassistant.util.yaml.loader.os.walk') def test_include_dir_merge_named_recursive(mock_walk): """Test include dir merge named yaml.""" mock_walk.return_value = [ @@ -257,7 +258,7 @@ def test_include_dir_merge_named_recursive(mock_walk): with io.StringIO(conf) as file: assert '.ignore' in mock_walk.return_value[0][1], \ "Expecting .ignore in here" - doc = yaml.yaml.safe_load(file) + doc = yaml_loader.yaml.safe_load(file) assert 'tmp2' in mock_walk.return_value[0][1] assert '.ignore' not in mock_walk.return_value[0][1] assert doc["key"] == { @@ -268,12 +269,12 @@ def test_include_dir_merge_named_recursive(mock_walk): } -@patch('homeassistant.util.yaml.open', create=True) +@patch('homeassistant.util.yaml.loader.open', create=True) def test_load_yaml_encoding_error(mock_open): """Test raising a UnicodeDecodeError.""" mock_open.side_effect = UnicodeDecodeError('', b'', 1, 0, '') with pytest.raises(HomeAssistantError): - yaml.load_yaml('test') + yaml_loader.load_yaml('test') def test_dump(): @@ -392,16 +393,16 @@ class TestSecrets(unittest.TestCase): def test_secrets_keyring(self): """Test keyring fallback & get_password.""" - yaml.keyring = None # Ensure its not there + yaml_loader.keyring = None # Ensure its not there yaml_str = 'http:\n api_password: !secret http_pw_keyring' - with pytest.raises(yaml.HomeAssistantError): + with pytest.raises(HomeAssistantError): load_yaml(self._yaml_path, yaml_str) - yaml.keyring = FakeKeyring({'http_pw_keyring': 'yeah'}) + yaml_loader.keyring = FakeKeyring({'http_pw_keyring': 'yeah'}) _yaml = load_yaml(self._yaml_path, yaml_str) assert {'http': {'api_password': 'yeah'}} == _yaml - @patch.object(yaml, 'credstash') + @patch.object(yaml_loader, 'credstash') def test_secrets_credstash(self, mock_credstash): """Test credstash fallback & get_password.""" mock_credstash.getSecret.return_value = 'yeah' @@ -413,10 +414,10 @@ class TestSecrets(unittest.TestCase): def test_secrets_logger_removed(self): """Ensure logger: debug was removed.""" - with pytest.raises(yaml.HomeAssistantError): + with pytest.raises(HomeAssistantError): load_yaml(self._yaml_path, 'api_password: !secret logger') - @patch('homeassistant.util.yaml._LOGGER.error') + @patch('homeassistant.util.yaml.loader._LOGGER.error') def test_bad_logger_value(self, mock_error): """Ensure logger: debug was removed.""" yaml.clear_secret_cache()