From c08862679dd5191fe7f303a5bd71520dcf1bb757 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 May 2019 16:01:51 -0700 Subject: [PATCH 01/67] Bumped version to 0.94.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1dcea3e2daf..f00a3d5c9d7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 94 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0b0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 78ffb6f3e612c261381da64aff9215897fad115c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 May 2019 09:32:29 -0700 Subject: [PATCH 02/67] Updated frontend to 20190530.0 --- homeassistant/components/frontend/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 72885690223..cb6ce89198e 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==20190529.0" + "home-assistant-frontend==20190530.0" ], "dependencies": [ "api", diff --git a/requirements_all.txt b/requirements_all.txt index 4f5ada38c44..cfa7122b1b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -577,7 +577,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190529.0 +home-assistant-frontend==20190530.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b6b2616964c..00707961ec6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -148,7 +148,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190529.0 +home-assistant-frontend==20190530.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 From 2bfe7aa21979b50e334aa2f1ade802293b45326f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 30 May 2019 17:35:47 +0200 Subject: [PATCH 03/67] Update azure-pipelines.yml for check version (#24194) --- azure-pipelines.yml | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 7a1e6e550d7..ce7def09821 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -8,7 +8,6 @@ trigger: tags: include: - '*' - variables: - name: versionBuilder value: '3.2' @@ -18,6 +17,7 @@ variables: - group: wheels - group: github + jobs: - job: 'Wheels' @@ -96,8 +96,30 @@ jobs: displayName: 'Run wheels build' -- job: 'Release' +- job: 'VersionValidate' condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') + pool: + vmImage: 'ubuntu-latest' + steps: + - task: UsePythonVersion@0 + displayName: 'Use Python 3.7' + inputs: + versionSpec: '3.7' + - script: | + setup_version="$(python setup.py -V)" + branch_version="$(Build.SourceBranchName)" + + if [ "${setup_version}" != "${branch_version}" ]; then + echo "Version of tag ${branch_version} don't match with ${setup_version}!" + exit 1 + fi + displayName: 'Check version of branch/tag' + + +- job: 'Release' + condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate') + dependsOn: + - 'VersionValidate' timeoutInMinutes: 120 pool: vmImage: 'ubuntu-16.04' From ca89d6184c9cdbdd15a7a674e3abcb5a435b5fa1 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 30 May 2019 18:46:08 +0200 Subject: [PATCH 04/67] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index ce7def09821..2f5792cfea6 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -117,7 +117,7 @@ jobs: - job: 'Release' - condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate') + condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate')) dependsOn: - 'VersionValidate' timeoutInMinutes: 120 From acc9fd0382106a693f93e7761257e75de8ec3a17 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 May 2019 04:37:01 -0700 Subject: [PATCH 05/67] Dynamic panels (#24184) * Don't require all panel urls to be registered * Allow removing panels, fire event when panels updated --- homeassistant/components/calendar/__init__.py | 2 +- homeassistant/components/config/__init__.py | 2 +- homeassistant/components/frontend/__init__.py | 96 +++++++++++-------- .../components/hassio/addon_panel.py | 2 +- homeassistant/components/history/__init__.py | 2 +- homeassistant/components/logbook/__init__.py | 2 +- homeassistant/components/lovelace/__init__.py | 2 +- homeassistant/components/mailbox/__init__.py | 2 +- homeassistant/components/map/__init__.py | 2 +- .../components/panel_custom/__init__.py | 2 +- .../components/panel_iframe/__init__.py | 2 +- .../components/shopping_list/__init__.py | 2 +- .../components/websocket_api/permissions.py | 2 + tests/components/frontend/test_init.py | 71 ++++++++++++-- 14 files changed, 133 insertions(+), 58 deletions(-) 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/config/__init__.py b/homeassistant/components/config/__init__.py index 8cd8856c1ec..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): diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 8d7f7213787..8a692d6f272 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -1,5 +1,4 @@ """Handle the frontend for Home Assistant.""" -import asyncio import json import logging import os @@ -26,6 +25,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' @@ -97,6 +97,28 @@ SCHEMA_GET_TRANSLATIONS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ }) +def generate_negative_index_regex(): + """Generate regex for index.""" + skip = [ + # files + "service_worker.js", + "robots.txt", + "onboarding.html", + "manifest.json", + ] + for folder in ( + "static", + "frontend_latest", + "frontend_es5", + "local", + "auth", + "api", + ): + # Regex matching static, static/, static/index.html + skip.append("{}(/|/.+|)".format(folder)) + return r"(?!(" + "|".join(skip) + r")).*" + + class Panel: """Abstract class for panels.""" @@ -128,15 +150,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 +164,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 +256,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.register_view(IndexView(repo_path)) - @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')]) - 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')]) - - 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() @@ -324,6 +333,9 @@ class IndexView(HomeAssistantView): url = '/' name = 'frontend:index' requires_auth = False + extra_urls = [ + "/{extra:%s}" % generate_negative_index_regex() + ] def __init__(self, repo_path): """Initialize the frontend view.""" @@ -349,6 +361,10 @@ class IndexView(HomeAssistantView): """Serve the index view.""" hass = request.app['hass'] + if (request.path != '/' and + request.url.parts[1] not in hass.data[DATA_PANELS]): + raise web.HTTPNotFound + if not hass.components.onboarding.async_is_onboarded(): return web.Response(status=302, headers={ 'location': '/onboarding.html' 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/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/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/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index f550f85bcef..b1b9cf1a524 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -53,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 }) diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 939cc4a2aa2..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)) 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/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_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/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/websocket_api/permissions.py b/homeassistant/components/websocket_api/permissions.py index 753c5688d18..887573f4abb 100644 --- a/homeassistant/components/websocket_api/permissions.py +++ b/homeassistant/components/websocket_api/permissions.py @@ -14,11 +14,13 @@ 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, diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index ee10b986697..09628b5d3fc 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -8,10 +8,11 @@ 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, generate_negative_index_regex, + 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 +233,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 +265,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) @@ -331,3 +348,43 @@ async def test_auth_authorize(mock_http_client): resp = await mock_http_client.get(authorizejs.groups(0)[0]) assert resp.status == 200 assert 'public' in resp.headers.get('cache-control') + + +def test_index_regex(): + """Test the index regex.""" + pattern = re.compile('/' + generate_negative_index_regex()) + + for should_match in ( + '/', + '/lovelace', + '/lovelace/default_view', + '/map', + '/config', + ): + assert pattern.match(should_match), should_match + + for should_not_match in ( + '/service_worker.js', + '/manifest.json', + '/onboarding.html', + '/manifest.json', + 'static', + 'static/', + 'static/index.html', + 'frontend_latest', + 'frontend_latest/', + 'frontend_latest/index.html', + 'frontend_es5', + 'frontend_es5/', + 'frontend_es5/index.html', + 'local', + 'local/', + 'local/index.html', + 'auth', + 'auth/', + 'auth/index.html', + '/api', + '/api/', + '/api/logbook', + ): + assert not pattern.match(should_not_match), should_not_match From 325001933df202fd1717c39f94a8c9c9e5d835e6 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Thu, 30 May 2019 18:48:58 +0200 Subject: [PATCH 06/67] Fix ESPHome discovered when already exists (#24187) * Fix ESPHome discovered when already exists * Update .coveragerc --- .coveragerc | 1 + homeassistant/components/esphome/__init__.py | 169 +++--------------- .../components/esphome/config_flow.py | 21 ++- .../components/esphome/entry_data.py | 107 +++++++++++ tests/components/esphome/test_config_flow.py | 29 ++- 5 files changed, 183 insertions(+), 144 deletions(-) create mode 100644 homeassistant/components/esphome/entry_data.py diff --git a/.coveragerc b/.coveragerc index 030c48cd10c..967c560198c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -172,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 diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index d42bbb725dd..395c145e5df 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -2,12 +2,11 @@ import asyncio import logging import math -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional from aioesphomeapi import ( - COMPONENT_TYPE_TO_INFO, APIClient, APIConnectionError, DeviceInfo, - EntityInfo, EntityState, ServiceCall, UserService, UserServiceArgType) -import attr + APIClient, APIConnectionError, DeviceInfo, EntityInfo, EntityState, + ServiceCall, UserService, UserServiceArgType) import voluptuous as vol from homeassistant import const @@ -19,8 +18,7 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, async_dispatcher_send) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.json import JSONEncoder @@ -30,16 +28,14 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType # Import config flow so that it's added to the registry from .config_flow import EsphomeFlowHandler # noqa +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 @@ -59,95 +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.""" - 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. @@ -159,7 +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.""" - hass.data.setdefault(DOMAIN, {}) + hass.data.setdefault(DATA_KEY, {}) host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] @@ -171,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, @@ -186,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 @@ -253,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 = [] @@ -285,17 +172,16 @@ 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.""" async def try_connect(tries: int = 0, is_disconnect: bool = True) -> None: @@ -351,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: @@ -371,7 +257,7 @@ async def _async_setup_device_registry(hass: HomeAssistantType, async def _register_service(hass: HomeAssistantType, entry_data: RuntimeEntryData, - service: 'UserService'): + service: UserService): service_name = '{}_{}'.format(entry_data.device_info.name, service.name) schema = {} for arg in service.args: @@ -391,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 = [] @@ -424,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: @@ -467,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 = {} @@ -498,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 @@ -519,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) @@ -603,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/config_flow.py b/homeassistant/components/esphome/config_flow.py index f2344e40b2a..283d09e7919 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): @@ -76,10 +78,25 @@ class EsphomeFlowHandler(config_entries.ConfigFlow): async def async_step_zeroconf(self, user_input: ConfigType): """Handle zeroconf discovery.""" - address = user_input['properties'].get( - 'address', user_input['hostname'][:-1]) + # 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 + already_configured = data.device_info.name == node_name + + if already_configured: return self.async_abort( reason='already_configured' ) 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/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 5aeb9d1c045..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"]) @@ -254,3 +254,30 @@ async def test_discovery_already_configured_ip(hass, mock_client): 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' From 4ca588deaeea460c21661273eaa5ee7ab606d303 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Thu, 30 May 2019 17:40:38 +0100 Subject: [PATCH 07/67] homekit_controller no longer logs with transient network errors causing crypto failures as it will auto recover (#24193) --- homeassistant/components/homekit_controller/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 1fcbddbb400..f1ddf1faacf 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -95,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( @@ -106,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 From 84719d944aa3f6056f42647b9456a72f1a3486c2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 May 2019 09:49:21 -0700 Subject: [PATCH 08/67] Update hass-nabucasa (#24197) --- homeassistant/components/cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/requirements_all.txt b/requirements_all.txt index cfa7122b1b7..2be17312b34 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -547,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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00707961ec6..c9ed5bc00ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -136,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 From 1ad495070dd8c9fe4bb93458b66c1f5a1ea2f8a0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 May 2019 14:59:14 -0700 Subject: [PATCH 09/67] Bumped version to 0.94.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f00a3d5c9d7..a74d6bf7ece 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 94 -PATCH_VERSION = '0b0' +PATCH_VERSION = '0b1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From bfc8d2457c6114e4cffd0f9a0847299aa5d03098 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 31 May 2019 10:53:34 +0200 Subject: [PATCH 10/67] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 56 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 2f5792cfea6..fc511615b60 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -16,6 +16,7 @@ variables: - group: docker - group: wheels - group: github + - group: twine jobs: @@ -24,7 +25,7 @@ jobs: condition: eq(variables['Build.SourceBranchName'], 'dev') timeoutInMinutes: 360 pool: - vmImage: 'ubuntu-16.04' + vmImage: 'ubuntu-latest' strategy: maxParallel: 3 matrix: @@ -114,15 +115,53 @@ jobs: exit 1 fi displayName: 'Check version of branch/tag' + - script: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + jq curl + + release="$(Build.SourceBranchName)" + created_by="$(curl -s https://api.github.com/repos/home-assistant/home-assistant/releases/tags/${release} | jq --raw-output '.author.login')" + + if [[ "${created_by}" =~ ^(balloob|pvizeli|fabaff|robbiet480)$ ]]; then + exit 0 + fi + + echo "${created_by} is not allowed to create an release!" + exit 1 + displayName: 'Check rights' -- job: 'Release' +- job: 'ReleasePython' + condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate')) + dependsOn: + - 'VersionValidate' + pool: + vmImage: 'ubuntu-latest' + steps: + - task: UsePythonVersion@0 + displayName: 'Use Python 3.7' + inputs: + versionSpec: '3.7' + - script: pip install twine + displayName: 'Install twine' + - script: python setup.py sdist bdist_wheel + displayName: 'Build package' + - script: | + export TWINE_USERNAME="$(twineUser)" + export TWINE_PASSWORD="$(twinePassword)" + + twine upload dist/* --skip-existing + displayName: 'Upload pypi' + + +- job: 'ReleaseDocker' condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate')) dependsOn: - 'VersionValidate' timeoutInMinutes: 120 pool: - vmImage: 'ubuntu-16.04' + vmImage: 'ubuntu-latest' strategy: maxParallel: 5 matrix: @@ -167,16 +206,17 @@ jobs: displayName: 'Build Release' -- job: 'ReleasePublish' - condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('Release')) +- job: 'ReleaseHassio' + condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('ReleaseDocker')) dependsOn: - - 'Release' + - 'ReleaseDocker' pool: - vmImage: 'ubuntu-16.04' + vmImage: 'ubuntu-latest' steps: - script: | + sudo apt-get update sudo apt-get install -y --no-install-recommends \ - git jq + git jq curl git config --global user.name "Pascal Vizeli" git config --global user.email "pvizeli@syshack.ch" From 9be1b72ed78f6e7d3a316c0e728d14d8a216ba83 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Fri, 31 May 2019 11:27:27 +0200 Subject: [PATCH 11/67] Fix ESPHome config flow with invalid config entry (#24213) --- homeassistant/components/esphome/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 283d09e7919..ad18e681021 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -94,7 +94,8 @@ class EsphomeFlowHandler(config_entries.ConfigFlow): data = self.hass.data[DATA_KEY][ entry.entry_id] # type: RuntimeEntryData # Node names are unique in the network - already_configured = data.device_info.name == node_name + if data.device_info is not None: + already_configured = data.device_info.name == node_name if already_configured: return self.async_abort( From 4fa6f2e54f2406db1e68d3df70db6691b8e45d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Herv=C3=A9?= Date: Fri, 31 May 2019 09:17:50 +0200 Subject: [PATCH 12/67] Bump oauthlib version (#24111) * Bump oauthlib version * Bump fitbit instead * Update requirements --- homeassistant/components/fitbit/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/requirements_all.txt b/requirements_all.txt index 2be17312b34..1a12b793d87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -453,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 From 16edcd99389b24adf0f30b9b3dff11cc1cfd45fb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 May 2019 14:08:05 -0700 Subject: [PATCH 13/67] Allow discovery flows to be discovered via zeroconf/ssdp (#24199) --- homeassistant/helpers/config_entry_flow.py | 3 +++ tests/helpers/test_config_entry_flow.py | 12 +++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 6d200a39c85..7c087a1ee64 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -81,6 +81,9 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow): return await self.async_step_confirm() + async_step_zeroconf = async_step_discovery + async_step_ssdp = 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/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 5f8a642333a..eda62e1614c 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -75,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' From 052641e620e151f6b7af142012769ea18e09fb83 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 11:26:05 -0700 Subject: [PATCH 14/67] Instantiate lock inside event loop (#24203) --- homeassistant/helpers/entity_platform.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 7908440e92b..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() + self._process_updates = None # Platform is None for the EntityComponent "catch-all" EntityPlatform # which powers entity_component.add_entities @@ -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 " From 6371eca14d4f187216dce1dd3acce90d34ef2594 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 May 2019 16:23:42 -0700 Subject: [PATCH 15/67] Improve error handling (#24204) --- homeassistant/components/ssdp/__init__.py | 6 ++--- tests/components/ssdp/test_init.py | 29 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index af24dd22a89..aecca614e73 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -135,15 +135,15 @@ class Scanner: if not xml: resp = await session.get(xml_location, timeout=5) xml = await resp.text() - except aiohttp.ClientError as err: + except (aiohttp.ClientError, asyncio.TimeoutError) as err: _LOGGER.debug("Error fetching %s: %s", xml_location, err) - return None + return {} try: tree = ElementTree.fromstring(xml) except ElementTree.ParseError as err: _LOGGER.debug("Error parsing %s: %s", xml_location, err) - return None + return {} return util.etree_to_dict(tree).get('root', {}).get('device', {}) diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 7ded5f12329..4b1e27d2dc8 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -1,6 +1,10 @@ """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 @@ -76,3 +80,28 @@ async def test_scan_match_device_type(hass, aioclient_mock): 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) From 46cc6e199b74572b2298a8a8c34d273b9947d657 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 31 May 2019 20:34:06 +0200 Subject: [PATCH 16/67] Axis - Handle Vapix error messages (#24215) --- homeassistant/components/axis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 27c108b334c..dc64e90ba9a 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -3,7 +3,7 @@ "name": "Axis", "config_flow": true, "documentation": "https://www.home-assistant.io/components/axis", - "requirements": ["axis==23"], + "requirements": ["axis==24"], "dependencies": [], "zeroconf": ["_axis-video._tcp.local."], "codeowners": ["@kane610"] diff --git a/requirements_all.txt b/requirements_all.txt index 1a12b793d87..25509c7f56c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -212,7 +212,7 @@ av==6.1.2 # avion==0.10 # homeassistant.components.axis -axis==23 +axis==24 # homeassistant.components.azure_event_hub azure-eventhub==1.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c9ed5bc00ba..d03208c9a1d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -70,7 +70,7 @@ apns2==0.3.0 av==6.1.2 # homeassistant.components.axis -axis==23 +axis==24 # homeassistant.components.zha bellows-homeassistant==0.7.3 From 35f57842875cfa3fdc561887de1f3fa4b4cc3802 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 31 May 2019 14:30:58 +0200 Subject: [PATCH 17/67] Don't follow redirect on ingress itself (#24218) * Don't follow redirect on ingress itself * Fix comment --- homeassistant/components/hassio/ingress.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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) From 52e33c2aa2423c0dcc1df49cc721309c53d9c2d2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 11:27:05 -0700 Subject: [PATCH 18/67] Use resource for index routing. (#24223) --- homeassistant/components/frontend/__init__.py | 105 +++++++++++------- tests/components/frontend/test_init.py | 43 +------ 2 files changed, 66 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 8a692d6f272..a18ed6eb3d1 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -4,9 +4,10 @@ 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 @@ -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' @@ -97,28 +97,6 @@ SCHEMA_GET_TRANSLATIONS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ }) -def generate_negative_index_regex(): - """Generate regex for index.""" - skip = [ - # files - "service_worker.js", - "robots.txt", - "onboarding.html", - "manifest.json", - ] - for folder in ( - "static", - "frontend_latest", - "frontend_es5", - "local", - "auth", - "api", - ): - # Regex matching static, static/, static/index.html - skip.append("{}(/|/.+|)".format(folder)) - return r"(?!(" + "|".join(skip) + r")).*" - - class Panel: """Abstract class for panels.""" @@ -256,7 +234,7 @@ async def async_setup(hass, config): if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - hass.http.register_view(IndexView(repo_path)) + hass.http.app.router.register_resource(IndexView(repo_path, hass)) for panel in ('kiosk', 'states', 'profile'): async_register_built_in_panel(hass, panel) @@ -327,21 +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 - extra_urls = [ - "/{extra:%s}" % generate_negative_index_regex() - ] - - 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 @@ -357,14 +378,10 @@ 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 (request.path != '/' and - request.url.parts[1] not in hass.data[DATA_PANELS]): - raise web.HTTPNotFound - if not hass.components.onboarding.async_is_onboarded(): return web.Response(status=302, headers={ 'location': '/onboarding.html' @@ -383,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/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 09628b5d3fc..c362499db15 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -8,8 +8,7 @@ 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, generate_negative_index_regex, - EVENT_PANELS_UPDATED) + CONF_EXTRA_HTML_URL_ES5, EVENT_PANELS_UPDATED) from homeassistant.components.websocket_api.const import TYPE_RESULT from tests.common import mock_coro, async_capture_events @@ -348,43 +347,3 @@ async def test_auth_authorize(mock_http_client): resp = await mock_http_client.get(authorizejs.groups(0)[0]) assert resp.status == 200 assert 'public' in resp.headers.get('cache-control') - - -def test_index_regex(): - """Test the index regex.""" - pattern = re.compile('/' + generate_negative_index_regex()) - - for should_match in ( - '/', - '/lovelace', - '/lovelace/default_view', - '/map', - '/config', - ): - assert pattern.match(should_match), should_match - - for should_not_match in ( - '/service_worker.js', - '/manifest.json', - '/onboarding.html', - '/manifest.json', - 'static', - 'static/', - 'static/index.html', - 'frontend_latest', - 'frontend_latest/', - 'frontend_latest/index.html', - 'frontend_es5', - 'frontend_es5/', - 'frontend_es5/index.html', - 'local', - 'local/', - 'local/index.html', - 'auth', - 'auth/', - 'auth/index.html', - '/api', - '/api/', - '/api/logbook', - ): - assert not pattern.match(should_not_match), should_not_match From 3eeccc1a653e3cec731c584e9bf013ae8a78f0c6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 11:58:48 -0700 Subject: [PATCH 19/67] Add manifest support for homekit discovery (#24225) * Add manifest support for homekit discovery * Add a space after model check * Update comment --- homeassistant/components/lifx/manifest.json | 5 ++ homeassistant/components/zeroconf/__init__.py | 60 ++++++++++++--- homeassistant/generated/zeroconf.py | 4 + homeassistant/helpers/config_entry_flow.py | 1 + script/hassfest/manifest.py | 3 + script/hassfest/zeroconf.py | 74 ++++++++++++++++--- tests/components/zeroconf/test_init.py | 54 +++++++++++--- 7 files changed, 169 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index ca9b578432b..fd74d9831fc 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -7,6 +7,11 @@ "aiolifx==0.6.7", "aiolifx_effects==0.2.2" ], + "homekit": { + "models": [ + "LIFX" + ] + }, "dependencies": [], "codeowners": [ "@amelchio" diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index fe757b8ae85..2f93020b4d5 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__) -from homeassistant.generated.zeroconf import ZEROCONF +from homeassistant.generated.zeroconf import ZEROCONF, HOMEKIT _LOGGER = logging.getLogger(__name__) @@ -24,6 +24,7 @@ ATTR_NAME = 'name' ATTR_PROPERTIES = 'properties' ZEROCONF_TYPE = '_home-assistant._tcp.local.' +HOMEKIT_TYPE = '_hap._tcp.local.' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({}), @@ -50,21 +51,30 @@ def setup(hass, config): def service_update(zeroconf, service_type, name, state_change): """Service state changed.""" - if state_change is ServiceStateChange.Added: - 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 state_change != ServiceStateChange.Added: + return - for domain in ZEROCONF[service_type]: - hass.add_job( - hass.config_entries.flow.async_init( - domain, context={'source': DOMAIN}, data=info - ) + 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) @@ -75,6 +85,36 @@ def setup(hass, config): 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 = {} diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 05b0a0247b9..024bb89dc99 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -18,3 +18,7 @@ ZEROCONF = { "homekit_controller" ] } + +HOMEKIT = { + "LIFX ": "lifx" +} diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 7c087a1ee64..c3e5195131b 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -83,6 +83,7 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow): 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.""" diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 31181ed76bd..3e25ab31712 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -17,6 +17,9 @@ MANIFEST_SCHEMA = vol.Schema({ 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/zeroconf.py b/script/hassfest/zeroconf.py index 1ed9575c95f..25e8da99b55 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -1,5 +1,5 @@ """Generate zeroconf file.""" -from collections import OrderedDict +from collections import OrderedDict, defaultdict import json from typing import Dict @@ -13,12 +13,15 @@ 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 = {} + service_type_dict = defaultdict(list) + homekit_dict = {} for domain in sorted(integrations): integration = integrations[domain] @@ -26,17 +29,30 @@ def generate_and_validate(integrations: Dict[str, Integration]): if not integration.manifest: continue - service_types = integration.manifest.get('zeroconf') + service_types = integration.manifest.get('zeroconf', []) + homekit = integration.manifest.get('homekit', {}) + homekit_models = homekit.get('models', []) - if not service_types: + if not service_types and not homekit_models: continue try: with open(str(integration.path / "config_flow.py")) as fp: - if ' async_step_zeroconf(' not in fp.read(): + 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', @@ -45,16 +61,50 @@ def generate_and_validate(integrations: Dict[str, Integration]): continue for service_type in service_types: - - if service_type not in service_type_dict: - service_type_dict[service_type] = [] - service_type_dict[service_type].append(domain) - data = OrderedDict((key, service_type_dict[key]) - for key in sorted(service_type_dict)) + for model in homekit_models: + # We add a space, as we want to test for it to be model + space. + model += " " - return BASE.format(json.dumps(data, indent=4)) + 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): diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index e7d7756fe7c..27c1dc75749 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1,6 +1,7 @@ """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 @@ -8,6 +9,13 @@ 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]( @@ -23,18 +31,44 @@ def get_service_info_mock(service_type, name): properties={b'macaddress': b'ABCDEF012345'}) -async def test_setup(hass): +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') as MockServiceBrowser, \ - patch.object(zeroconf.Zeroconf, 'get_service_info') as \ - mock_get_service_info: - - MockServiceBrowser.side_effect = service_update_mock - mock_get_service_info.side_effect = get_service_info_mock - + 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(MockServiceBrowser.mock_calls) == len(zc_gen.ZEROCONF) + 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' From 5c8f209aa74af710309c80b6c53fbf37e208c259 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 13:45:41 -0700 Subject: [PATCH 20/67] Bumped version to 0.94.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a74d6bf7ece..8a9dfa40454 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 94 -PATCH_VERSION = '0b1' +PATCH_VERSION = '0b2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 958b894020a6f4225373b0d9f44f04023eec9c44 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 31 May 2019 23:10:09 +0200 Subject: [PATCH 21/67] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index fc511615b60..ae53e94a9b4 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -143,8 +143,8 @@ jobs: displayName: 'Use Python 3.7' inputs: versionSpec: '3.7' - - script: pip install twine - displayName: 'Install twine' + - script: pip install twine wheel + displayName: 'Install tools' - script: python setup.py sdist bdist_wheel displayName: 'Build package' - script: | From 7d1a02feb167e5ea37685b1565ecf13cc8b1d467 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 22:59:16 -0700 Subject: [PATCH 22/67] Log HomeKit model (#24229) --- homeassistant/components/homekit_controller/config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index c7100f3159e..2ce8c0db6b7 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -126,14 +126,16 @@ 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'].replace('._hap._tcp.local.', ''), + 'name': name, } # If multiple HomekitControllerFlowHandler end up getting created From 0cdea28e2a1770b33d8eb79e6b226cdfcda40076 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 1 Jun 2019 00:51:55 +0200 Subject: [PATCH 23/67] Don't allow more than one config flow per discovered Axis device (#24230) --- homeassistant/components/axis/config_flow.py | 7 +++++++ homeassistant/components/axis/strings.json | 1 + 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index fc2051e4925..2aa5c4de16e 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -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/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" }, From dc8d4ac8e4b59ebbfd3cbfa49371225d26138074 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 22:59:35 -0700 Subject: [PATCH 24/67] Add GPSLogger device_info and unique_id (#24231) --- .../components/gpslogger/device_tracker.py | 14 ++++++++ tests/components/gpslogger/test_init.py | 33 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index 81a4fb3e7f8..49d421cbc8c 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -45,6 +45,7 @@ class GPSLoggerEntity(DeviceTrackerEntity): self._battery = battery self._location = location self._unsub_dispatcher = None + self._unique_id = device @property def battery_level(self): @@ -81,6 +82,19 @@ class GPSLoggerEntity(DeviceTrackerEntity): """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.""" diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index 2cffa86f393..dbc283895fc 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -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.""" @@ -172,6 +178,33 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id): 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.' From 362f23a950143586177c4c5c774d3db0f14b1f5f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 22:59:44 -0700 Subject: [PATCH 25/67] GeoFency unique ID and device info (#24232) --- .../components/geofency/device_tracker.py | 14 ++++++++++++++ tests/components/geofency/test_init.py | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index e340272c966..0c60d5ef2ce 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -43,6 +43,7 @@ class GeofencyEntity(DeviceTrackerEntity): self._location_name = location_name self._gps = gps self._unsub_dispatcher = None + self._unique_id = device @property def device_state_attributes(self): @@ -74,6 +75,19 @@ class GeofencyEntity(DeviceTrackerEntity): """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.""" diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 718eb259db5..18f119a7539 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -217,6 +217,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.""" From 35ffac1e015c9ee5b160592f87c58e6c6b266a50 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 1 Jun 2019 02:00:10 -0400 Subject: [PATCH 26/67] add a deprecation warning for tplink device_tracker (#24236) * add a deprecation warning for tplink device_tracker * reword the warning a bit --- homeassistant/components/tplink/device_tracker.py | 6 ++++++ 1 file changed, 6 insertions(+) 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 From fe2e5089abe726fd5fc37975e983b3448058b68f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 23:01:45 -0700 Subject: [PATCH 27/67] Mobile app to use device tracker config entry (#24238) * Mobile app to use device tracker config entry * Lint * Re-use device_info * Lint --- .../components/mobile_app/__init__.py | 16 +- homeassistant/components/mobile_app/const.py | 2 + .../components/mobile_app/device_tracker.py | 137 ++++++++++++++++++ homeassistant/components/mobile_app/entity.py | 16 +- .../components/mobile_app/helpers.py | 15 +- .../components/mobile_app/manifest.json | 1 - .../components/mobile_app/webhook.py | 49 +------ tests/components/mobile_app/__init__.py | 75 +--------- tests/components/mobile_app/conftest.py | 60 ++++++++ .../mobile_app/test_device_tracker.py | 68 +++++++++ tests/components/mobile_app/test_entity.py | 3 - tests/components/mobile_app/test_http_api.py | 3 +- tests/components/mobile_app/test_webhook.py | 35 ++--- .../mobile_app/test_websocket_api.py | 3 +- 14 files changed, 318 insertions(+), 165 deletions(-) create mode 100644 homeassistant/components/mobile_app/device_tracker.py create mode 100644 tests/components/mobile_app/conftest.py create mode 100644 tests/components/mobile_app/test_device_tracker.py diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index abb7bcb7628..839aa8a6c3b 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -7,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_DEVICE_TRACKER, 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.""" @@ -24,7 +26,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): DATA_BINARY_SENSOR: {}, DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], - DATA_DEVICES: {}, DATA_SENSOR: {} } @@ -33,6 +34,7 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: app_config.get(DATA_DELETED_IDS, []), DATA_DEVICES: {}, + DATA_DEVICE_TRACKER: {}, DATA_SENSOR: app_config.get(DATA_SENSOR, {}), DATA_STORE: store, } @@ -83,10 +85,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 diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 8b33406216e..8cb5aa12731 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -25,6 +25,7 @@ DATA_BINARY_SENSOR = 'binary_sensor' DATA_CONFIG_ENTRIES = 'config_entries' DATA_DELETED_IDS = 'deleted_ids' DATA_DEVICES = 'devices' +DATA_DEVICE_TRACKER = 'device_tracker' DATA_SENSOR = 'sensor' DATA_STORE = 'store' @@ -160,6 +161,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..19aade50876 --- /dev/null +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -0,0 +1,137 @@ +"""Device tracker platform that adds support for OwnTracks over MQTT.""" +import logging + +from homeassistant.core import callback +from homeassistant.components.device_tracker.const import ( + DOMAIN, SOURCE_TYPE_GPS) +from homeassistant.components.device_tracker.config_entry import ( + DeviceTrackerEntity +) +from .const import ( + DOMAIN as MA_DOMAIN, + + 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__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up OwnTracks based off an entry.""" + @callback + def _receive_data(data): + """Receive set location.""" + dev_id = entry.data[ATTR_DEVICE_ID] + device = hass.data[MA_DOMAIN][DOMAIN].get(dev_id) + + if device is not None: + device.update_data(data) + return + + device = hass.data[MA_DOMAIN][DOMAIN][dev_id] = MobileAppEntity( + entry, data + ) + async_add_entities([device]) + + hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_LOCATION_UPDATE.format(entry.entry_id), _receive_data) + return True + + +class MobileAppEntity(DeviceTrackerEntity): + """Represent a tracked device.""" + + def __init__(self, entry, data): + """Set up OwnTracks entity.""" + self._entry = entry + self._data = data + + @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_ALTITUDE, ATTR_COURSE, + ATTR_SPEED, ATTR_VERTICAL_ACCURACY): + 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) + + @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 969817b62c7..85c6231daa8 100644 --- a/homeassistant/components/mobile_app/manifest.json +++ b/homeassistant/components/mobile_app/manifest.json @@ -7,7 +7,6 @@ "PyNaCl==1.3.0" ], "dependencies": [ - "device_tracker", "http", "webhook" ], diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 4f867885d4f..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,15 +20,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.template import attach from homeassistant.helpers.typing import HomeAssistantType -from homeassistant.util import slugify - -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, @@ -45,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, @@ -151,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: slugify(registration[ATTR_DEVICE_NAME]), - 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/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..448bd9181c8 --- /dev/null +++ b/tests/components/mobile_app/test_device_tracker.py @@ -0,0 +1,68 @@ +"""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') + 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') + 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 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( From e5cbf01ce10f8d6ac65f8ca75064313d4de9eef6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 23:05:57 -0700 Subject: [PATCH 28/67] Bumped version to 0.94.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 8a9dfa40454..dc279f9725f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 94 -PATCH_VERSION = '0b2' +PATCH_VERSION = '0b3' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 09c43e8854dc5684fdce7b07771f141115692278 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Jun 2019 14:27:25 -0700 Subject: [PATCH 29/67] Updated frontend to 20190601.0 --- homeassistant/components/frontend/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index cb6ce89198e..bd93a0f481c 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==20190530.0" + "home-assistant-frontend==20190601.0" ], "dependencies": [ "api", diff --git a/requirements_all.txt b/requirements_all.txt index 25509c7f56c..fcb60d5068b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -577,7 +577,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190530.0 +home-assistant-frontend==20190601.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d03208c9a1d..ed0ed7c8cb6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -148,7 +148,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190530.0 +home-assistant-frontend==20190601.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 From bf85e18d456277ebfe0a54eebc8363e67f8b970f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Jun 2019 01:04:12 -0700 Subject: [PATCH 30/67] Do not use the cache dir for PIP installs (#24233) --- homeassistant/requirements.py | 7 +++++-- homeassistant/util/package.py | 5 ++++- tests/test_requirements.py | 21 ++++++++++++++------- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index ca34a4bbae4..1164eff4eb8 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -44,12 +44,15 @@ async def async_process_requirements(hass: HomeAssistant, name: str, 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 '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 pkg_util.is_docker_env(): + not is_docker: kwargs['target'] = os.path.join(config_dir, 'deps') return kwargs diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 272a097b24c..6f6d03d67b6 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -49,7 +49,8 @@ def is_installed(package: str) -> bool: def install_package(package: str, upgrade: bool = True, target: Optional[str] = None, constraints: Optional[str] = None, - find_links: Optional[str] = None) -> bool: + 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. @@ -58,6 +59,8 @@ 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: diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 35264c2e1b4..bbf86278bd2 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -30,9 +30,8 @@ class TestRequirements: @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_denv, 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( @@ -42,14 +41,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_denv, 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 @@ -60,7 +61,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): @@ -108,7 +111,9 @@ async def test_install_with_wheels_index(hass): 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)) + constraints=os.path.join('ha_package_path', CONSTRAINT_FILE), + no_cache_dir=True, + ) async def test_install_on_docker(hass): @@ -135,4 +140,6 @@ async def test_install_on_docker(hass): print(mock_inst.call_args) assert mock_inst.call_args == call( 'hello==1.0.0', - constraints=os.path.join('ha_package_path', CONSTRAINT_FILE)) + constraints=os.path.join('ha_package_path', CONSTRAINT_FILE), + no_cache_dir=True, + ) From 22f68d70a71f88a2ec1fde4b8d53a4692009708b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Jun 2019 14:34:39 -0700 Subject: [PATCH 31/67] Bumped version to 0.94.0b4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index dc279f9725f..e8ce6c04f7a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 94 -PATCH_VERSION = '0b3' +PATCH_VERSION = '0b4' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From a8c73ffb93b6fae58fe6410e8c511a6ae41a00fd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Jun 2019 13:52:52 -0700 Subject: [PATCH 32/67] Updated frontend to 20190602.0 --- homeassistant/components/frontend/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index bd93a0f481c..820f17a98bf 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==20190601.0" + "home-assistant-frontend==20190602.0" ], "dependencies": [ "api", diff --git a/requirements_all.txt b/requirements_all.txt index fcb60d5068b..7adb9d4bed0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -577,7 +577,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190601.0 +home-assistant-frontend==20190602.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ed0ed7c8cb6..6c5f41a054f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -148,7 +148,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190601.0 +home-assistant-frontend==20190602.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 From d2d3f27f85106cd242eca62bc43bf3ee7623d62b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Jun 2019 13:57:21 -0700 Subject: [PATCH 33/67] Add restore state to OwnTracks device tracker (#24256) * Add restore state to OwnTracks device tracker * Lint * Also store entity devices * Update test_device_tracker.py --- .../components/owntracks/device_tracker.py | 136 ++++++++++-------- .../owntracks/test_device_tracker.py | 44 ++++++ 2 files changed, 124 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index fb9fedf26fa..d74fea43c29 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -2,10 +2,19 @@ import logging from homeassistant.core import callback -from homeassistant.components.device_tracker.const import ENTITY_ID_FORMAT +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__) @@ -14,53 +23,52 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): """Set up OwnTracks based off an entry.""" @callback - def _receive_data(dev_id, host_name, gps, attributes, gps_accuracy=None, - battery=None, source_type=None, location_name=None): + def _receive_data(dev_id, **data): """Receive set location.""" - device = hass.data[OT_DOMAIN]['devices'].get(dev_id) + entity = hass.data[OT_DOMAIN]['devices'].get(dev_id) - if device is not None: - device.update_data( - host_name=host_name, - gps=gps, - attributes=attributes, - gps_accuracy=gps_accuracy, - battery=battery, - source_type=source_type, - location_name=location_name, - ) + if entity is not None: + entity.update_data(data) return - device = hass.data[OT_DOMAIN]['devices'][dev_id] = OwnTracksEntity( - dev_id=dev_id, - host_name=host_name, - gps=gps, - attributes=attributes, - gps_accuracy=gps_accuracy, - battery=battery, - source_type=source_type, - location_name=location_name, + entity = hass.data[OT_DOMAIN]['devices'][dev_id] = OwnTracksEntity( + dev_id, data ) - async_add_entities([device]) + async_add_entities([entity]) hass.data[OT_DOMAIN]['context'].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 -class OwnTracksEntity(DeviceTrackerEntity): +class OwnTracksEntity(DeviceTrackerEntity, RestoreEntity): """Represent a tracked device.""" - def __init__(self, dev_id, host_name, gps, attributes, gps_accuracy, - battery, source_type, location_name): + def __init__(self, dev_id, data=None): """Set up OwnTracks entity.""" self._dev_id = dev_id - self._host_name = host_name - self._gps = gps - self._gps_accuracy = gps_accuracy - self._location_name = location_name - self._attributes = attributes - self._battery = battery - self._source_type = source_type + self._data = data or {} self.entity_id = ENTITY_ID_FORMAT.format(dev_id) @property @@ -71,43 +79,45 @@ class OwnTracksEntity(DeviceTrackerEntity): @property def battery_level(self): """Return the battery level of the device.""" - return self._battery + return self._data.get('battery') @property def device_state_attributes(self): """Return device specific attributes.""" - return self._attributes + return self._data.get('attributes') @property def location_accuracy(self): """Return the gps accuracy of the device.""" - return self._gps_accuracy + return self._data.get('gps_accuracy') @property def latitude(self): """Return latitude value of the device.""" - if self._gps is not None: - return self._gps[0] + # Check with "get" instead of "in" because value can be None + if self._data.get('gps'): + return self._data['gps'][0] return None @property def longitude(self): """Return longitude value of the device.""" - if self._gps is not None: - return self._gps[1] + # Check with "get" instead of "in" because value can be None + if self._data.get('gps'): + return self._data['gps'][1] return None @property def location_name(self): """Return a location name for the current location of the device.""" - return self._location_name + return self._data.get('location_name') @property def name(self): """Return the name of the device.""" - return self._host_name + return self._data.get('host_name') @property def should_poll(self): @@ -117,26 +127,40 @@ class OwnTracksEntity(DeviceTrackerEntity): @property def source_type(self): """Return the source type, eg gps or router, of the device.""" - return self._source_type + return self._data.get('source_type') @property def device_info(self): """Return the device info.""" return { - 'name': self._host_name, + 'name': self.name, 'identifiers': {(OT_DOMAIN, self._dev_id)}, } - @callback - def update_data(self, host_name, gps, attributes, gps_accuracy, - battery, source_type, location_name): - """Mark the device as seen.""" - self._host_name = host_name - self._gps = gps - self._gps_accuracy = gps_accuracy - self._location_name = location_name - self._attributes = attributes - self._battery = battery - self._source_type = source_type + async def async_added_to_hass(self): + """Call when entity about to be added to Home Assistant.""" + await super().async_added_to_hass() + # Don't restore if we got set up with data. + if self._data: + return + + state = await self.async_get_last_state() + + if state is None: + return + + attr = state.attributes + self._data = { + 'host_name': state.name, + 'gps': (attr[ATTR_LATITUDE], attr[ATTR_LONGITUDE]), + 'gps_accuracy': attr[ATTR_GPS_ACCURACY], + 'battery': attr[ATTR_BATTERY_LEVEL], + 'source_type': attr[ATTR_SOURCE_TYPE], + } + + @callback + def update_data(self, data): + """Mark the device as seen.""" + self._data = data self.async_write_ha_state() diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index b81f434a2c1..7d8d48de586 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -1491,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'] From 5f3bcedbba005ceef64bc8c35aa4be86d8ec19c2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2019 01:30:56 -0700 Subject: [PATCH 34/67] Mobile app device tracker to restore state (#24266) --- .../components/mobile_app/__init__.py | 3 +- homeassistant/components/mobile_app/const.py | 1 - .../components/mobile_app/device_tracker.py | 80 +++++++++++++------ .../mobile_app/test_device_tracker.py | 52 +++++++++++- 4 files changed, 106 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 839aa8a6c3b..1d34babe3ac 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -7,7 +7,7 @@ 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_DEVICE_TRACKER, DATA_SENSOR, DATA_STORE, + DATA_DEVICES, DATA_SENSOR, DATA_STORE, DOMAIN, STORAGE_KEY, STORAGE_VERSION) from .http_api import RegistrationsView @@ -34,7 +34,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: app_config.get(DATA_DELETED_IDS, []), DATA_DEVICES: {}, - DATA_DEVICE_TRACKER: {}, DATA_SENSOR: app_config.get(DATA_SENSOR, {}), DATA_STORE: store, } diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 8cb5aa12731..922835c1d40 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -25,7 +25,6 @@ DATA_BINARY_SENSOR = 'binary_sensor' DATA_CONFIG_ENTRIES = 'config_entries' DATA_DELETED_IDS = 'deleted_ids' DATA_DEVICES = 'devices' -DATA_DEVICE_TRACKER = 'device_tracker' DATA_SENSOR = 'sensor' DATA_STORE = 'store' diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index 19aade50876..22435fadc16 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -2,14 +2,17 @@ import logging from homeassistant.core import callback -from homeassistant.components.device_tracker.const import ( - DOMAIN, SOURCE_TYPE_GPS) +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 ( - DOMAIN as MA_DOMAIN, - ATTR_ALTITUDE, ATTR_BATTERY, ATTR_COURSE, @@ -26,37 +29,29 @@ from .const import ( 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.""" - @callback - def _receive_data(data): - """Receive set location.""" - dev_id = entry.data[ATTR_DEVICE_ID] - device = hass.data[MA_DOMAIN][DOMAIN].get(dev_id) - - if device is not None: - device.update_data(data) - return - - device = hass.data[MA_DOMAIN][DOMAIN][dev_id] = MobileAppEntity( - entry, data - ) - async_add_entities([device]) - - hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_LOCATION_UPDATE.format(entry.entry_id), _receive_data) + entity = MobileAppEntity(entry) + async_add_entities([entity]) return True -class MobileAppEntity(DeviceTrackerEntity): +class MobileAppEntity(DeviceTrackerEntity, RestoreEntity): """Represent a tracked device.""" - def __init__(self, entry, data): + 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): @@ -72,8 +67,7 @@ class MobileAppEntity(DeviceTrackerEntity): def device_state_attributes(self): """Return device specific attributes.""" attrs = {} - for key in (ATTR_ALTITUDE, ATTR_COURSE, - ATTR_SPEED, ATTR_VERTICAL_ACCURACY): + for key in ATTR_KEYS: value = self._data.get(key) if value is not None: attrs[key] = value @@ -130,6 +124,42 @@ class MobileAppEntity(DeviceTrackerEntity): """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[ATTR_LATITUDE], attr[ATTR_LONGITUDE]), + ATTR_GPS_ACCURACY: attr[ATTR_GPS_ACCURACY], + ATTR_BATTERY: attr[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.""" diff --git a/tests/components/mobile_app/test_device_tracker.py b/tests/components/mobile_app/test_device_tracker.py index 448bd9181c8..53f9ad6f6dd 100644 --- a/tests/components/mobile_app/test_device_tracker.py +++ b/tests/components/mobile_app/test_device_tracker.py @@ -22,7 +22,7 @@ async def test_sending_location(hass, create_registrations, webhook_client): assert resp.status == 200 await hass.async_block_till_done() - state = hass.states.get('device_tracker.test_1') + state = hass.states.get('device_tracker.test_1_2') assert state is not None assert state.name == 'Test 1' assert state.state == 'bar' @@ -54,7 +54,7 @@ async def test_sending_location(hass, create_registrations, webhook_client): assert resp.status == 200 await hass.async_block_till_done() - state = hass.states.get('device_tracker.test_1') + 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' @@ -66,3 +66,51 @@ async def test_sending_location(hass, create_registrations, webhook_client): 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 From 89d7c0af91554df70f08d507d73e775f03e58e01 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2019 01:29:45 -0700 Subject: [PATCH 35/67] Add restore state to Geofency (#24268) * Add restore state to Geofency * Lint --- .../components/geofency/device_tracker.py | 40 +++++++++++++++++-- tests/components/geofency/test_init.py | 30 ++++++++------ 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index 0c60d5ef2ce..f9a7df638eb 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -1,12 +1,18 @@ """Support for the Geofency device tracker platform.""" import logging +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 GF_DOMAIN, TRACKER_UPDATE @@ -30,15 +36,28 @@ async def async_setup_entry(hass, config_entry, async_add_entities): hass.data[GF_DOMAIN]['unsub_device_tracker'][config_entry.entry_id] = \ async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) + # Restore previously loaded devices + dev_reg = await device_registry.async_get_registry(hass) + dev_ids = { + identifier[1] + for device in dev_reg.devices.values() + for identifier in device.identifiers + if identifier[0] == GF_DOMAIN + } + + if dev_ids: + hass.data[GF_DOMAIN]['devices'].update(dev_ids) + async_add_entities(GeofencyEntity(dev_id) for dev_id in dev_ids) + return True -class GeofencyEntity(DeviceTrackerEntity): +class GeofencyEntity(DeviceTrackerEntity, RestoreEntity): """Represent a tracked device.""" - def __init__(self, device, gps, location_name, attributes): + def __init__(self, device, gps=None, location_name=None, attributes=None): """Set up Geofency entity.""" - self._attributes = attributes + self._attributes = attributes or {} self._name = device self._location_name = location_name self._gps = gps @@ -95,12 +114,27 @@ class GeofencyEntity(DeviceTrackerEntity): 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): diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 18f119a7539..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 @@ -291,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) @@ -303,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 From 704cdac874bde0ba333da07ca386330e3e79000f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 3 Jun 2019 08:36:38 +0000 Subject: [PATCH 36/67] Bumped version to 0.94.0b5 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e8ce6c04f7a..aabee94dc39 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 94 -PATCH_VERSION = '0b4' +PATCH_VERSION = '0b5' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 2b0e56932b821499503f270be0b30be1b12a5cfc Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 3 Jun 2019 11:51:34 +0200 Subject: [PATCH 37/67] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 149 ++++++++++++++++++++++++++++++++------------ 1 file changed, 109 insertions(+), 40 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 5d518af1103..71b061f2682 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -8,23 +8,24 @@ trigger: tags: include: - '*' - variables: - name: versionBuilder value: '3.2' - name: versionWheels - value: '0.3' + value: '0.7' - group: docker - group: wheels - group: github + - group: twine + jobs: - job: 'Wheels' - condition: eq(variables['Build.SourceBranchName'], 'dev') + condition: or(eq(variables['Build.SourceBranchName'], 'dev'), eq(variables['Build.SourceBranchName'], 'master')) timeoutInMinutes: 360 pool: - vmImage: 'ubuntu-16.04' + vmImage: 'ubuntu-latest' strategy: maxParallel: 3 matrix: @@ -40,10 +41,12 @@ jobs: buildArch: 'aarch64' steps: - script: | + sudo apt-get update sudo apt-get install -y --no-install-recommends \ qemu-user-static \ - binfmt-support - + 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 @@ -57,49 +60,114 @@ jobs: - script: sudo docker pull homeassistant/$(buildArch)-wheels:$(versionWheels) displayName: 'Install wheels builder' - script: | - cp requirements_all.txt requirements_hassio.txt + 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 - # Enable because we can build it - sed -i "s|# pytradfri|pytradfri|g" requirements_hassio.txt - sed -i "s|# pybluez|pybluez|g" requirements_hassio.txt - sed -i "s|# bluepy|bluepy|g" requirements_hassio.txt - sed -i "s|# beacontools|beacontools|g" requirements_hassio.txt - sed -i "s|# RPi.GPIO|RPi.GPIO|g" requirements_hassio.txt - sed -i "s|# raspihats|raspihats|g" requirements_hassio.txt - sed -i "s|# rpi-rf|rpi-rf|g" requirements_hassio.txt - sed -i "s|# blinkt|blinkt|g" requirements_hassio.txt - sed -i "s|# fritzconnection|fritzconnection|g" requirements_hassio.txt - sed -i "s|# pyuserinput|pyuserinput|g" requirements_hassio.txt - sed -i "s|# evdev|evdev|g" requirements_hassio.txt - sed -i "s|# smbus-cffi|smbus-cffi|g" requirements_hassio.txt - sed -i "s|# i2csense|i2csense|g" requirements_hassio.txt - sed -i "s|# python-eq3bt|python-eq3bt|g" requirements_hassio.txt - sed -i "s|# pycups|pycups|g" requirements_hassio.txt - sed -i "s|# homekit|homekit|g" requirements_hassio.txt - sed -i "s|# decora_wifi|decora_wifi|g" requirements_hassio.txt - sed -i "s|# decora|decora|g" requirements_hassio.txt - sed -i "s|# PySwitchbot|PySwitchbot|g" requirements_hassio.txt - sed -i "s|# pySwitchmate|pySwitchmate|g" requirements_hassio.txt - - # Disable because of error - sed -i "s|insteonplm|# insteonplm|g" requirements_hassio.txt + 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 https://wheels.hass.io \ - --requirement requirements_hassio.txt \ + --index $(wheelsIndex) \ + --requirement requirements_wheels.txt \ + --requirement-diff requirements_diff.txt \ --upload rsync \ --remote wheels@$(wheelsHost):/opt/wheels displayName: 'Run wheels build' -- job: 'Release' +- job: 'VersionValidate' condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') + pool: + vmImage: 'ubuntu-latest' + steps: + - task: UsePythonVersion@0 + displayName: 'Use Python 3.7' + inputs: + versionSpec: '3.7' + - script: | + setup_version="$(python setup.py -V)" + branch_version="$(Build.SourceBranchName)" + + if [ "${setup_version}" != "${branch_version}" ]; then + echo "Version of tag ${branch_version} don't match with ${setup_version}!" + exit 1 + fi + displayName: 'Check version of branch/tag' + - script: | + sudo apt-get install -y --no-install-recommends \ + jq curl + + release="$(Build.SourceBranchName)" + created_by="$(curl -s https://api.github.com/repos/home-assistant/home-assistant/releases/tags/${release} | jq --raw-output '.author.login')" + + if [[ "${created_by}" =~ ^(balloob|pvizeli|fabaff|robbiet480)$ ]]; then + exit 0 + fi + + echo "${created_by} is not allowed to create an release!" + exit 1 + displayName: 'Check rights' + + +- job: 'ReleasePython' + condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate')) + dependsOn: + - 'VersionValidate' + pool: + vmImage: 'ubuntu-latest' + steps: + - task: UsePythonVersion@0 + displayName: 'Use Python 3.7' + inputs: + versionSpec: '3.7' + - script: pip install twine wheel + displayName: 'Install tools' + - script: python setup.py sdist bdist_wheel + displayName: 'Build package' + - script: | + export TWINE_USERNAME="$(twineUser)" + export TWINE_PASSWORD="$(twinePassword)" + + twine upload dist/* --skip-existing + displayName: 'Upload pypi' + + +- job: 'ReleaseDocker' + condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate')) + dependsOn: + - 'VersionValidate' timeoutInMinutes: 120 pool: - vmImage: 'ubuntu-16.04' + vmImage: 'ubuntu-latest' strategy: maxParallel: 5 matrix: @@ -144,16 +212,16 @@ jobs: displayName: 'Build Release' -- job: 'ReleasePublish' - condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('Release')) +- job: 'ReleaseHassio' + condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('ReleaseDocker')) dependsOn: - - 'Release' + - 'ReleaseDocker' pool: - vmImage: 'ubuntu-16.04' + vmImage: 'ubuntu-latest' steps: - script: | sudo apt-get install -y --no-install-recommends \ - git jq + git jq curl git config --global user.name "Pascal Vizeli" git config --global user.email "pvizeli@syshack.ch" @@ -184,3 +252,4 @@ jobs: git commit -am "Bump Home Assistant $version" git push + displayName: 'Update version files' From a00d8a493d0b9e0d4a94b54dd2584556e161bce9 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 3 Jun 2019 12:31:31 +0200 Subject: [PATCH 38/67] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 71b061f2682..7a2967dc495 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -5,6 +5,7 @@ trigger: branches: include: - dev + - master tags: include: - '*' From 9ed5b70d01a5562508880c7a89cc722b9460ac13 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 3 Jun 2019 18:26:01 +0200 Subject: [PATCH 39/67] deCONZ migrate to SSDP discovery (#24252) * Migrate deCONZ to use new SSDP discovery Add new discovery info manufacturer URL to be able to separate Hue and deCONZ bridges * Mark deCONZ as migrated in Discovery component * Fix tests * Fix Hue discovery ignore deCONZ bridge * Less snake more badger * Mushroom * Fix indentation * Config flow ignore manufacturer url that is not philips --- .../components/deconz/config_flow.py | 21 +++++++++---- homeassistant/components/deconz/manifest.json | 5 ++++ homeassistant/components/deconz/strings.json | 6 ++-- .../components/discovery/__init__.py | 3 +- homeassistant/components/hue/config_flow.py | 6 ++++ homeassistant/components/hue/strings.json | 3 +- homeassistant/components/ssdp/__init__.py | 2 ++ homeassistant/generated/ssdp.py | 1 + tests/components/deconz/test_config_flow.py | 30 +++++++++++++++---- tests/components/hue/test_config_flow.py | 21 +++++++++++-- 10 files changed, 78 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 24eb3dd4d5d..cf172ad7991 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -9,12 +9,14 @@ 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' @@ -149,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: @@ -162,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/manifest.json b/homeassistant/components/deconz/manifest.json index 08a01cd1379..56ea52b7693 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -6,6 +6,11 @@ "requirements": [ "pydeconz==59" ], + "ssdp": { + "manufacturer": [ + "Royal Philips Electronics" + ] + }, "dependencies": [], "codeowners": [ "@kane610" 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/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index ee6a8590515..0541b5d223a 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -25,7 +25,6 @@ DOMAIN = 'discovery' SCAN_INTERVAL = timedelta(seconds=300) SERVICE_APPLE_TV = 'apple_tv' SERVICE_DAIKIN = 'daikin' -SERVICE_DECONZ = 'deconz' SERVICE_DLNA_DMR = 'dlna_dmr' SERVICE_ENIGMA2 = 'enigma2' SERVICE_FREEBOX = 'freebox' @@ -48,7 +47,6 @@ SERVICE_XIAOMI_GW = 'xiaomi_gw' CONFIG_ENTRY_HANDLERS = { SERVICE_DAIKIN: 'daikin', - SERVICE_DECONZ: 'deconz', 'google_cast': 'cast', SERVICE_HEOS: 'heos', SERVICE_TELLDUSLIVE: 'tellduslive', @@ -98,6 +96,7 @@ OPTIONAL_SERVICE_HANDLERS = { MIGRATED_SERVICE_HANDLERS = { 'axis': None, + 'deconz': None, 'esphome': None, 'ikea_tradfri': None, 'homekit': None, diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 4167027bf89..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): @@ -143,6 +146,9 @@ class HueFlowHandler(config_entries.ConfigFlow): 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') diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 079ac1a2b8d..78b990d5f42 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -24,7 +24,8 @@ "unknown": "Unknown error occurred", "cannot_connect": "Unable to connect to the bridge", "already_configured": "Bridge is already configured", - "already_in_progress": "Config flow for bridge is already in progress." + "already_in_progress": "Config flow for bridge is already in progress.", + "not_hue_bridge": "Not a Hue bridge" } } } diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index aecca614e73..e250b9c16fb 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -23,6 +23,7 @@ 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' @@ -164,6 +165,7 @@ def info_from_entry(entry, device_info): 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') diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 897f68a6521..cc1d286bf5f 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -8,6 +8,7 @@ SSDP = { "device_type": {}, "manufacturer": { "Royal Philips Electronics": [ + "deconz", "hue" ] }, diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index 46b0084b01b..2b9f2c013b0 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -168,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={ @@ -195,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' diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 37cece0bbd8..b7736e62390 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -195,13 +195,26 @@ async def test_bridge_ssdp(hass): side_effect=errors.AuthenticationRequired): 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_ssdp_discover_other_bridge(hass): + """Test that discovery ignores other bridges.""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_ssdp({ + 'manufacturerURL': 'http://www.notphilips.com' + }) + + assert result['type'] == 'abort' + + async def test_bridge_ssdp_emulated_hue(hass): """Test if discovery info is from an emulated hue instance.""" flow = config_flow.HueFlowHandler() @@ -211,7 +224,8 @@ async def test_bridge_ssdp_emulated_hue(hass): result = await flow.async_step_ssdp({ 'name': 'HASS Bridge', 'host': '0.0.0.0', - 'serial': '1234' + 'serial': '1234', + 'manufacturerURL': config_flow.HUE_MANUFACTURERURL }) assert result['type'] == 'abort' @@ -229,7 +243,8 @@ async def test_bridge_ssdp_already_configured(hass): result = await flow.async_step_ssdp({ 'host': '0.0.0.0', - 'serial': '1234' + 'serial': '1234', + 'manufacturerURL': config_flow.HUE_MANUFACTURERURL }) assert result['type'] == 'abort' From 5085ce8ab10cff4733961e47d7ac72f3b1710987 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 3 Jun 2019 21:40:16 +0200 Subject: [PATCH 40/67] Add temperature sensor support to google smarthome thermostat device (#24264) * Add temperature sensor support to google smarthome thermostat device * fix lint for trait_test * Reset temperature unit in tests * Address comment --- .../components/google_assistant/const.py | 2 + .../components/google_assistant/trait.py | 158 +++++++++++------- .../components/google_assistant/test_trait.py | 33 ++++ 3 files changed, 133 insertions(+), 60 deletions(-) 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/trait.py b/homeassistant/components/google_assistant/trait.py index f9590a07b95..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, ) @@ -550,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] @@ -687,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: diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 28cab008201..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, @@ -1380,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 From ee71d2ca60aacdb77f020ccd66fe7cd8db0c90f5 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Mon, 3 Jun 2019 16:30:30 +0200 Subject: [PATCH 41/67] Bump aioesphomeapi to 2.1.0 (#24278) * Bump aioesphomeapi to 2.1.0 * Update requirements txt --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 71d233fee2e..a986a864189 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/esphome", "requirements": [ - "aioesphomeapi==2.0.1" + "aioesphomeapi==2.1.0" ], "dependencies": [], "zeroconf": ["_esphomelib._tcp.local."], diff --git a/requirements_all.txt b/requirements_all.txt index 7adb9d4bed0..30449f87738 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -126,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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c5f41a054f..e2ec52ecf03 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -45,7 +45,7 @@ aioautomatic==0.6.5 aiobotocore==0.10.2 # homeassistant.components.esphome -aioesphomeapi==2.0.1 +aioesphomeapi==2.1.0 # homeassistant.components.emulated_hue # homeassistant.components.http From 014cc14b7e0c4f7d366be485f1420572caa4ebcd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2019 11:43:13 -0700 Subject: [PATCH 42/67] Fix cors on the index view (#24283) --- homeassistant/components/http/cors.py | 5 +++++ tests/components/http/test_cors.py | 12 ++++++++++++ 2 files changed, 17 insertions(+) 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/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 From 4a71593ffdd476d0de3d36142fd005507c5793de Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2019 12:37:27 -0700 Subject: [PATCH 43/67] Remove deps folder in config when on Docker (#24284) * Remove deps folder in config * Fix tests * Fix tests with docker check --- homeassistant/config.py | 16 +++++++++++++--- tests/test_config.py | 25 ++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 9465025cfd2..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 @@ -31,6 +31,7 @@ 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.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM from homeassistant.helpers.entity_values import EntityValues @@ -333,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 @@ -357,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__) diff --git a/tests/test_config.py b/tests/test_config.py index 5579679937b..29058f185ad 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -256,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) @@ -275,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' From 281fe93a265881502ceccfd212d57a805186dfb1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2019 12:41:45 -0700 Subject: [PATCH 44/67] Bumped version to 0.94.0b6 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index aabee94dc39..eea2602ce49 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 94 -PATCH_VERSION = '0b5' +PATCH_VERSION = '0b6' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From bad920fa87db55089eb27c23206a2d0252c872b1 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 4 Jun 2019 12:42:45 +0200 Subject: [PATCH 45/67] Bumped version to 0.94.0b7 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index eea2602ce49..56802281e81 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 94 -PATCH_VERSION = '0b6' +PATCH_VERSION = '0b7' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From b67d32824c22873d9b5e7e886a9ef3f644c9f427 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Jun 2019 08:50:25 -0700 Subject: [PATCH 46/67] Updated frontend to 20190604.0 --- homeassistant/components/frontend/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 820f17a98bf..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==20190602.0" + "home-assistant-frontend==20190604.0" ], "dependencies": [ "api", diff --git a/requirements_all.txt b/requirements_all.txt index 30449f87738..4f92fed078c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -577,7 +577,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190602.0 +home-assistant-frontend==20190604.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2ec52ecf03..b43ef070d6b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -148,7 +148,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190602.0 +home-assistant-frontend==20190604.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 From bb0867f1a89ef545409b5c9ec6bdfc9efd6623a8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Jun 2019 10:18:26 -0700 Subject: [PATCH 47/67] Guard against bad states in Mobile App/OwnTracks (#24292) --- homeassistant/components/mobile_app/device_tracker.py | 6 +++--- homeassistant/components/owntracks/device_tracker.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index 22435fadc16..7fb76f3af41 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -145,9 +145,9 @@ class MobileAppEntity(DeviceTrackerEntity, RestoreEntity): attr = state.attributes data = { - ATTR_GPS: (attr[ATTR_LATITUDE], attr[ATTR_LONGITUDE]), - ATTR_GPS_ACCURACY: attr[ATTR_GPS_ACCURACY], - ATTR_BATTERY: attr[ATTR_BATTERY_LEVEL], + 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 diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index d74fea43c29..ed2749262bd 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -153,10 +153,10 @@ class OwnTracksEntity(DeviceTrackerEntity, RestoreEntity): attr = state.attributes self._data = { 'host_name': state.name, - 'gps': (attr[ATTR_LATITUDE], attr[ATTR_LONGITUDE]), - 'gps_accuracy': attr[ATTR_GPS_ACCURACY], - 'battery': attr[ATTR_BATTERY_LEVEL], - 'source_type': attr[ATTR_SOURCE_TYPE], + '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), } @callback From d17f27b65cf4f09f589a82461900b1cd8b4a95e2 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 4 Jun 2019 20:04:20 +0200 Subject: [PATCH 48/67] Create progress file for pip installs (#24297) * Create progress file for pip installs * fix dedlock * unflacky test * Address comments * Lint * Types --- homeassistant/requirements.py | 20 ++++++++++++++++---- tests/test_requirements.py | 22 +++++++++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 1164eff4eb8..2ab4fe28bdc 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -1,6 +1,6 @@ """Module to handle installing requirements.""" import asyncio -from functools import partial +from pathlib import Path import logging import os from typing import Any, Dict, List, Optional @@ -11,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__) @@ -24,15 +25,16 @@ async def async_process_requirements(hass: HomeAssistant, name: str, if pip_lock is None: pip_lock = hass.data[DATA_PIP_LOCK] = asyncio.Lock() - 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 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 " @@ -42,6 +44,16 @@ 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() diff --git a/tests/test_requirements.py b/tests/test_requirements.py index bbf86278bd2..fc9dee20ed2 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -1,10 +1,11 @@ """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, async_process_requirements) + CONSTRAINT_FILE, async_process_requirements, PROGRESS_FILE, _install) from tests.common import ( get_test_home_assistant, MockModule, mock_coro, mock_integration) @@ -143,3 +144,22 @@ async def test_install_on_docker(hass): constraints=os.path.join('ha_package_path', CONSTRAINT_FILE), no_cache_dir=True, ) + + +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'} + + def assert_env(req, **passed_kwargs): + """Assert the env.""" + assert progress_path.exists() + assert req == 'hello' + assert passed_kwargs == kwargs + return True + + with patch('homeassistant.util.package.install_package', + side_effect=assert_env): + _install(hass, 'hello', kwargs) + + assert not progress_path.exists() From 185af1b42a72fdba6ce7419c46278b6ccb8b1ff1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Jun 2019 11:04:02 -0700 Subject: [PATCH 49/67] Run SSDP discovery in parallel (#24299) --- homeassistant/components/ssdp/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index e250b9c16fb..79c9cd94871 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -86,13 +86,16 @@ class Scanner: if not to_load: return - for entry, info, domains in to_load: + tasks = [] + for entry, info, domains in to_load: for domain in domains: _LOGGER.debug("Discovered %s at %s", domain, entry.location) - await self.hass.config_entries.flow.async_init( + 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.""" From 389da16947a1bb24dee0e0137f3e013886ca948f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Jun 2019 11:05:11 -0700 Subject: [PATCH 50/67] Upgrade Zeroconf to 0.23 (#24300) --- homeassistant/components/zeroconf/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index becd5d51c5a..1461a54d147 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -3,7 +3,7 @@ "name": "Zeroconf", "documentation": "https://www.home-assistant.io/components/zeroconf", "requirements": [ - "zeroconf==0.22.0" + "zeroconf==0.23.0" ], "dependencies": [ "api" diff --git a/requirements_all.txt b/requirements_all.txt index 4f92fed078c..debfb43b41e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1875,7 +1875,7 @@ youtube_dl==2019.05.11 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 b43ef070d6b..cd76ca3f748 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -352,7 +352,7 @@ vultr==0.1.2 wakeonlan==1.1.6 # homeassistant.components.zeroconf -zeroconf==0.22.0 +zeroconf==0.23.0 # homeassistant.components.zha zigpy-homeassistant==0.3.3 From 1096fe3d8707f7df5f077790dff393804c362c2d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Jun 2019 11:06:25 -0700 Subject: [PATCH 51/67] Bumped version to 0.94.0b8 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 56802281e81..eae31b11ce4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 94 -PATCH_VERSION = '0b7' +PATCH_VERSION = '0b8' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 3b60081e2ae3e4d01a2f5fa8ea2ef790aa1bd25f Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 4 Jun 2019 23:14:51 +0200 Subject: [PATCH 52/67] address is deprecated in favor of addresses (#24302) --- homeassistant/components/zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 2f93020b4d5..bdb1d52159c 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -127,7 +127,7 @@ def info_from_service(service): except UnicodeDecodeError: _LOGGER.warning("Unicode decode error on %s: %s", key, value) - address = service.address or service.address6 + address = service.addresses[0] info = { ATTR_HOST: str(ipaddress.ip_address(address)), From eca424656a4c7a58312ab2e17b1d5a9b9a8288d5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Jun 2019 14:06:49 -0700 Subject: [PATCH 53/67] Fix OwnTracks race condition (#24303) * Fix OwnTracks race condition * Lint --- .../components/owntracks/__init__.py | 12 +++++++++- .../components/owntracks/device_tracker.py | 2 +- tests/components/owntracks/test_init.py | 23 ++++++++++++++++++- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index a4df4303fa8..1cc7a050aec 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -192,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): @@ -222,10 +223,19 @@ class OwnTracksContext: return True + @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() + + # pylint: disable=method-hidden @callback def async_see(self, **data): """Send a see message to the device tracker.""" - raise NotImplementedError + self._pending_msg.append(data) @callback def async_see_beacons(self, hass, dev_id, kwargs_param): diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index ed2749262bd..742b7c34435 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -36,7 +36,7 @@ async def async_setup_entry(hass, entry, async_add_entities): ) async_add_entities([entity]) - hass.data[OT_DOMAIN]['context'].async_see = _receive_data + hass.data[OT_DOMAIN]['context'].set_async_see(_receive_data) # Restore previously loaded devices dev_reg = await device_registry.async_get_registry(hass) diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py index fafe9678e78..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 = { @@ -160,3 +160,24 @@ def test_returns_error_missing_device(mock_client): json = yield from resp.json() assert json == [] + + +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 = [] + + 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 From 13c38335930157e3f41c5c0cdf3ba2875ae099ad Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Jun 2019 14:34:06 -0700 Subject: [PATCH 54/67] Bumped version to 0.94.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index eae31b11ce4..58897e78d0c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 94 -PATCH_VERSION = '0b8' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From ce93a332a7553790e377cb8b6b9f40082f2d615a Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 5 Jun 2019 09:37:57 +0200 Subject: [PATCH 55/67] Update and rename azure-pipelines.yml to azure-pipelines-release.yml --- ...pelines.yml => azure-pipelines-release.yml | 93 +------------------ 1 file changed, 2 insertions(+), 91 deletions(-) rename azure-pipelines.yml => azure-pipelines-release.yml (57%) 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 35571a9105a..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') @@ -157,7 +69,7 @@ jobs: - script: | export TWINE_USERNAME="$(twineUser)" export TWINE_PASSWORD="$(twinePassword)" - + twine upload dist/* --skip-existing displayName: 'Upload pypi' @@ -221,7 +133,6 @@ jobs: vmImage: 'ubuntu-latest' steps: - script: | - sudo apt-get update sudo apt-get install -y --no-install-recommends \ git jq curl From 8d3c9bc2d056962be1c72fa14ae70cd8df2441f8 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 5 Jun 2019 17:13:40 +0200 Subject: [PATCH 56/67] Don't let zeroconf be smart with addresses (#24321) --- homeassistant/components/zeroconf/__init__.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index bdb1d52159c..289aba6ef56 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -3,12 +3,14 @@ # 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 @@ -42,8 +44,16 @@ def setup(hass, config): 'requires_api_password': True, } - info = ServiceInfo(ZEROCONF_TYPE, zeroconf_name, - port=hass.http.server_port, properties=params) + host_ip = util.get_local_ip() + + try: + host_ip_pton = socket.inet_pton(socket.AF_INET, host_ip) + except socket.error: + host_ip_pton = socket.inet_pton(socket.AF_INET6, host_ip) + + info = ServiceInfo(ZEROCONF_TYPE, zeroconf_name, None, + addresses=[host_ip_pton], port=hass.http.server_port, + properties=params) zeroconf = Zeroconf() From 09292d59182ed1ed3148dd2ec507d05f5839179c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 5 Jun 2019 22:15:18 +0200 Subject: [PATCH 57/67] Update azure-pipelines-release.yml for Azure Pipelines --- azure-pipelines-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index 4f37966f9f5..8f250f16ce3 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -78,7 +78,7 @@ jobs: condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate')) dependsOn: - 'VersionValidate' - timeoutInMinutes: 120 + timeoutInMinutes: 240 pool: vmImage: 'ubuntu-latest' strategy: From 798b72e1642f754848cc644f511093a7c4b29306 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2019 10:06:53 -0700 Subject: [PATCH 58/67] Add a discovery config flow to Wemo (#24208) --- .../components/discovery/__init__.py | 22 ++++++++-------- homeassistant/components/wemo/__init__.py | 25 +++++++++++++++---- homeassistant/components/wemo/config_flow.py | 15 +++++++++++ homeassistant/components/wemo/manifest.json | 6 +++++ homeassistant/components/wemo/strings.json | 15 +++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/ssdp.py | 3 +++ script/hassfest/ssdp.py | 4 ++- 8 files changed, 74 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/wemo/config_flow.py create mode 100644 homeassistant/components/wemo/strings.json diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 0541b5d223a..a7c306ad241 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -58,7 +58,6 @@ SERVICE_HANDLERS = { SERVICE_MOBILE_APP: ('mobile_app', None), SERVICE_HASS_IOS_APP: ('ios', None), SERVICE_NETGEAR: ('device_tracker', None), - SERVICE_WEMO: ('wemo', None), SERVICE_HASSIO: ('hassio', None), SERVICE_APPLE_TV: ('apple_tv', None), SERVICE_ENIGMA2: ('media_player', 'enigma2'), @@ -94,19 +93,20 @@ OPTIONAL_SERVICE_HANDLERS = { SERVICE_DLNA_DMR: ('media_player', 'dlna_dmr'), } -MIGRATED_SERVICE_HANDLERS = { - 'axis': None, - 'deconz': None, - 'esphome': None, - 'ikea_tradfri': None, - 'homekit': None, - 'philips_hue': None -} +MIGRATED_SERVICE_HANDLERS = [ + 'axis', + 'deconz', + 'esphome', + 'ikea_tradfri', + 'homekit', + 'philips_hue', + SERVICE_WEMO, +] DEFAULT_ENABLED = list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS) + \ - list(MIGRATED_SERVICE_HANDLERS) + MIGRATED_SERVICE_HANDLERS DEFAULT_DISABLED = list(OPTIONAL_SERVICE_HANDLERS) + \ - list(MIGRATED_SERVICE_HANDLERS) + MIGRATED_SERVICE_HANDLERS CONF_IGNORE = 'ignore' CONF_ENABLE = 'enable' diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index d921075bc1a..8353b52b9f0 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -4,6 +4,7 @@ import logging import requests import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.discovery import SERVICE_WEMO from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery @@ -68,22 +69,35 @@ CONFIG_SCHEMA = vol.Schema({ def setup(hass, config): """Set up for WeMo devices.""" + hass.data[DOMAIN] = config + + if DOMAIN in config: + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT})) + + return True + + +async def async_setup_entry(hass, entry): + """Set up a wemo config entry.""" import pywemo + config = hass.data[DOMAIN] + # Keep track of WeMo devices devices = [] # Keep track of WeMo device subscriptions for push updates global SUBSCRIPTION_REGISTRY SUBSCRIPTION_REGISTRY = pywemo.SubscriptionRegistry() - SUBSCRIPTION_REGISTRY.start() + await hass.async_add_executor_job(SUBSCRIPTION_REGISTRY.start) def stop_wemo(event): """Shutdown Wemo subscriptions and subscription thread on exit.""" _LOGGER.debug("Shutting down WeMo event subscriptions") SUBSCRIPTION_REGISTRY.stop() - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_wemo) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_wemo) def setup_url_for_device(device): """Determine setup.xml url for given device.""" @@ -119,7 +133,7 @@ def setup(hass, config): discovery.load_platform( hass, component, DOMAIN, discovery_info, config) - discovery.listen(hass, SERVICE_WEMO, discovery_dispatch) + discovery.async_listen(hass, SERVICE_WEMO, discovery_dispatch) def discover_wemo_devices(now): """Run discovery for WeMo devices.""" @@ -145,7 +159,7 @@ def setup(hass, config): if d[1].serialnumber == device.serialnumber]: devices.append((url, device)) - if config.get(DOMAIN, {}).get(CONF_DISCOVERY): + if config.get(DOMAIN, {}).get(CONF_DISCOVERY, DEFAULT_DISCOVERY): _LOGGER.debug("Scanning network for WeMo devices...") for device in pywemo.discover_devices(): if not [d[1] for d in devices @@ -168,6 +182,7 @@ def setup(hass, config): _LOGGER.debug("WeMo device discovery has finished") - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, discover_wemo_devices) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, discover_wemo_devices) return True diff --git a/homeassistant/components/wemo/config_flow.py b/homeassistant/components/wemo/config_flow.py new file mode 100644 index 00000000000..61094dbab32 --- /dev/null +++ b/homeassistant/components/wemo/config_flow.py @@ -0,0 +1,15 @@ +"""Config flow for Wemo.""" +from homeassistant.helpers import config_entry_flow +from homeassistant import config_entries +from . import DOMAIN + + +async def _async_has_devices(hass): + """Return if there are devices that can be discovered.""" + import pywemo + + return bool(pywemo.discover_devices()) + + +config_entry_flow.register_discovery_flow( + DOMAIN, 'Wemo', _async_has_devices, config_entries.CONN_CLASS_LOCAL_PUSH) diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index 238be891886..c610c28da39 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -1,10 +1,16 @@ { "domain": "wemo", "name": "Wemo", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/wemo", "requirements": [ "pywemo==0.4.34" ], + "ssdp": { + "manufacturer": [ + "Belkin International Inc." + ] + }, "dependencies": [], "codeowners": [ "@sqldiablo" diff --git a/homeassistant/components/wemo/strings.json b/homeassistant/components/wemo/strings.json new file mode 100644 index 00000000000..d4b40817cb3 --- /dev/null +++ b/homeassistant/components/wemo/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "title": "Wemo", + "step": { + "confirm": { + "title": "Wemo", + "description": "Do you want to set up Wemo?" + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of Wemo is possible.", + "no_devices_found": "No Wemo devices found on the network." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c9a8c593b27..87da17434b9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -49,6 +49,7 @@ FLOWS = [ "twilio", "unifi", "upnp", + "wemo", "zha", "zone", "zwave" diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index cc1d286bf5f..4da9f41a203 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -7,6 +7,9 @@ To update, run python3 -m hassfest SSDP = { "device_type": {}, "manufacturer": { + "Belkin International Inc.": [ + "wemo" + ], "Royal Philips Electronics": [ "deconz", "hue" diff --git a/script/hassfest/ssdp.py b/script/hassfest/ssdp.py index b5c4b9721c0..d9a50dd7c38 100644 --- a/script/hassfest/ssdp.py +++ b/script/hassfest/ssdp.py @@ -43,7 +43,9 @@ def generate_and_validate(integrations: Dict[str, Integration]): try: with open(str(integration.path / "config_flow.py")) as fp: - if ' async_step_ssdp(' not in fp.read(): + content = fp.read() + if (' async_step_ssdp(' not in content and + 'register_discovery_flow' not in content): integration.add_error( 'ssdp', 'Config flow has no async_step_ssdp') continue From ebc09017b8631409da160e4f927d1bde3afdb505 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 6 Jun 2019 03:07:30 -0700 Subject: [PATCH 59/67] Initiate websession inside event loop (#24331) --- homeassistant/components/tado/device_tracker.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 3bb62f328b9..31b424b9cd4 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -42,6 +42,7 @@ class TadoDeviceScanner(DeviceScanner): def __init__(self, hass, config): """Initialize the scanner.""" + self.hass = hass self.last_results = [] self.username = config[CONF_USERNAME] @@ -60,8 +61,7 @@ class TadoDeviceScanner(DeviceScanner): # The API URL always needs a username and password self.tadoapiurl += '?username={username}&password={password}' - self.websession = async_create_clientsession( - hass, cookie_jar=aiohttp.CookieJar(unsafe=True)) + self.websession = None self.success_init = asyncio.run_coroutine_threadsafe( self._async_update_info(), hass.loop @@ -92,6 +92,10 @@ class TadoDeviceScanner(DeviceScanner): """ _LOGGER.debug("Requesting Tado") + if self.websession is None: + self.websession = async_create_clientsession( + self.hass, cookie_jar=aiohttp.CookieJar(unsafe=True)) + last_results = [] try: From b8e20fcadf053b14c5c07b9f3e5577a26317a5a7 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 7 Jun 2019 21:22:02 +0200 Subject: [PATCH 60/67] Bump dependency (#24376) --- homeassistant/components/axis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index dc64e90ba9a..2b1bef9081e 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -3,7 +3,7 @@ "name": "Axis", "config_flow": true, "documentation": "https://www.home-assistant.io/components/axis", - "requirements": ["axis==24"], + "requirements": ["axis==25"], "dependencies": [], "zeroconf": ["_axis-video._tcp.local."], "codeowners": ["@kane610"] diff --git a/requirements_all.txt b/requirements_all.txt index debfb43b41e..9b135d7110a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -212,7 +212,7 @@ av==6.1.2 # avion==0.10 # homeassistant.components.axis -axis==24 +axis==25 # homeassistant.components.azure_event_hub azure-eventhub==1.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd76ca3f748..18562f53554 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -70,7 +70,7 @@ apns2==0.3.0 av==6.1.2 # homeassistant.components.axis -axis==24 +axis==25 # homeassistant.components.zha bellows-homeassistant==0.7.3 From d1b82e9edec80f55d5d439db40690db614e44533 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Fri, 7 Jun 2019 23:29:31 -0400 Subject: [PATCH 61/67] Updated pubnubsub-handler to 1.0.7 to fix crash on slow startup (#24388) --- homeassistant/components/wink/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wink/manifest.json b/homeassistant/components/wink/manifest.json index 118f7a19733..a878b084169 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.6", + "pubnubsub-handler==1.0.7", "python-wink==1.10.5" ], "dependencies": ["configurator"], diff --git a/requirements_all.txt b/requirements_all.txt index 9b135d7110a..8b635326058 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -918,7 +918,7 @@ psutil==5.6.2 ptvsd==4.2.8 # homeassistant.components.wink -pubnubsub-handler==1.0.6 +pubnubsub-handler==1.0.7 # homeassistant.components.pushbullet pushbullet.py==0.11.0 From 2fed0163474e50f83417a2fb6e74b9d6f79ec375 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Jun 2019 23:08:22 -0700 Subject: [PATCH 62/67] Fix automation failing to restore state (#24390) * Fix automation off * Fix tests --- .../components/automation/__init__.py | 71 +++++++++++-------- .../automation/test_homeassistant.py | 4 +- tests/components/automation/test_init.py | 22 +++--- 3 files changed, 55 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index beca5cd236c..90b5857b13c 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -190,6 +190,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self._last_triggered = None self._hidden = hidden self._initial_state = initial_state + self._is_enabled = False @property def name(self): @@ -216,7 +217,8 @@ class AutomationEntity(ToggleEntity, RestoreEntity): @property def is_on(self) -> bool: """Return True if entity is on.""" - return self._async_detach_triggers is not None + return (self._async_detach_triggers is not None or + self._is_enabled) async def async_added_to_hass(self) -> None: """Startup with initial state or previous state.""" @@ -239,37 +241,16 @@ class AutomationEntity(ToggleEntity, RestoreEntity): "initial state", self.entity_id, enable_automation) - if not enable_automation: - return - - # HomeAssistant is starting up - if self.hass.state == CoreState.not_running: - async def async_enable_automation(event): - """Start automation on startup.""" - await self.async_enable() - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, async_enable_automation) - - # HomeAssistant is running - else: + if enable_automation: await self.async_enable() async def async_turn_on(self, **kwargs) -> None: """Turn the entity on and update the state.""" - if self.is_on: - return - await self.async_enable() async def async_turn_off(self, **kwargs) -> None: """Turn the entity off.""" - if not self.is_on: - return - - self._async_detach_triggers() - self._async_detach_triggers = None - await self.async_update_ha_state() + await self.async_disable() async def async_trigger(self, variables, skip_condition=False, context=None): @@ -296,19 +277,51 @@ class AutomationEntity(ToggleEntity, RestoreEntity): async def async_will_remove_from_hass(self): """Remove listeners when removing automation from HASS.""" await super().async_will_remove_from_hass() - await self.async_turn_off() + await self.async_disable() async def async_enable(self): """Enable this automation entity. This method is a coroutine. """ - if self.is_on: + if self._is_enabled: return - self._async_detach_triggers = await self._async_attach_triggers( - self.async_trigger) - await self.async_update_ha_state() + self._is_enabled = True + + # HomeAssistant is starting up + if self.hass.state != CoreState.not_running: + self._async_detach_triggers = await self._async_attach_triggers( + self.async_trigger) + self.async_write_ha_state() + return + + async def async_enable_automation(event): + """Start automation on startup.""" + # Don't do anything if no longer enabled or already attached + if (not self._is_enabled or + self._async_detach_triggers is not None): + return + + self._async_detach_triggers = await self._async_attach_triggers( + self.async_trigger) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, async_enable_automation) + self.async_write_ha_state() + + async def async_disable(self): + """Disable the automation entity.""" + if not self._is_enabled: + return + + self._is_enabled = False + + if self._async_detach_triggers is not None: + self._async_detach_triggers() + self._async_detach_triggers = None + + self.async_write_ha_state() @property def device_state_attributes(self): diff --git a/tests/components/automation/test_homeassistant.py b/tests/components/automation/test_homeassistant.py index d9cb5313c3e..742a2aa857c 100644 --- a/tests/components/automation/test_homeassistant.py +++ b/tests/components/automation/test_homeassistant.py @@ -29,7 +29,7 @@ def test_if_fires_on_hass_start(hass): res = yield from async_setup_component(hass, automation.DOMAIN, config) assert res - assert not automation.is_on(hass, 'automation.hello') + assert automation.is_on(hass, 'automation.hello') assert len(calls) == 0 yield from hass.async_start() @@ -64,7 +64,7 @@ def test_if_fires_on_hass_shutdown(hass): } }) assert res - assert not automation.is_on(hass, 'automation.hello') + assert automation.is_on(hass, 'automation.hello') assert len(calls) == 0 yield from hass.async_start() diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 179c5f84895..81d7a8b257f 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -696,12 +696,12 @@ def test_initial_value_off(hass): assert len(calls) == 0 -@asyncio.coroutine -def test_initial_value_on(hass): +async def test_initial_value_on(hass): """Test initial value on.""" + hass.state = CoreState.not_running calls = async_mock_service(hass, 'test', 'automation') - res = yield from async_setup_component(hass, automation.DOMAIN, { + assert await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'initial_state': 'on', @@ -715,23 +715,23 @@ def test_initial_value_on(hass): } } }) - assert res assert automation.is_on(hass, 'automation.hello') + await hass.async_start() hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 1 -@asyncio.coroutine -def test_initial_value_off_but_restore_on(hass): +async def test_initial_value_off_but_restore_on(hass): """Test initial value off and restored state is turned on.""" + hass.state = CoreState.not_running calls = async_mock_service(hass, 'test', 'automation') mock_restore_cache(hass, ( State('automation.hello', STATE_ON), )) - res = yield from async_setup_component(hass, automation.DOMAIN, { + await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'initial_state': 'off', @@ -745,11 +745,11 @@ def test_initial_value_off_but_restore_on(hass): } } }) - assert res assert not automation.is_on(hass, 'automation.hello') + await hass.async_start() hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 0 @@ -858,7 +858,7 @@ def test_automation_not_trigger_on_bootstrap(hass): } }) assert res - assert not automation.is_on(hass, 'automation.hello') + assert automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event') yield from hass.async_block_till_done() From 7d9988fd753132f153c172ddc08e9f7fe00a6106 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Jun 2019 22:59:51 -0700 Subject: [PATCH 63/67] Add more HomeKit models for discovery (#24391) * Add more HomeKit models for discovery * Discover Tradfri with HomeKit * Add Wemo device info * Allow full match for HomeKit model * Fix tests --- .../homekit_controller/config_flow.py | 2 - homeassistant/components/hue/config_flow.py | 16 ++++++++ homeassistant/components/hue/manifest.json | 5 +++ .../components/tradfri/config_flow.py | 2 + .../components/tradfri/manifest.json | 5 +++ homeassistant/components/wemo/manifest.json | 5 +++ homeassistant/components/wemo/switch.py | 10 ++++- homeassistant/components/zeroconf/__init__.py | 2 +- homeassistant/generated/zeroconf.py | 5 ++- script/hassfest/ssdp.py | 2 +- script/hassfest/zeroconf.py | 7 +--- .../homekit_controller/test_config_flow.py | 2 +- tests/components/hue/test_config_flow.py | 35 +++++++++++++++++ tests/components/zeroconf/test_init.py | 39 +++++++++++++++---- 14 files changed, 118 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 2ce8c0db6b7..9ddb144ec9a 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -13,9 +13,7 @@ from .connection import get_bridge_information, get_accessory_name HOMEKIT_IGNORE = [ - 'BSB002', 'Home Assistant Bridge', - 'TRADFRI gateway', ] HOMEKIT_DIR = '.homekit' PAIRING_FILE = 'pairing.json' diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 9c81d144d1c..d57706f7ac8 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -175,6 +175,22 @@ class HueFlowHandler(config_entries.ConfigFlow): 'path': 'phue-{}.conf'.format(serial) }) + async def async_step_homekit(self, homekit_info): + """Handle HomeKit discovery.""" + # pylint: disable=unsupported-assignment-operation + host = self.context['host'] = homekit_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') + + return await self.async_step_import({ + 'host': host, + }) + async def async_step_import(self, import_info): """Import a new bridge as a config entry. diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index d16988529b1..c0c7c462f90 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -11,6 +11,11 @@ "Royal Philips Electronics" ] }, + "homekit": { + "models": [ + "BSB002" + ] + }, "dependencies": [], "codeowners": [ "@balloob" diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 76f6a8f5764..bfabf4fd12a 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -87,6 +87,8 @@ class FlowHandler(config_entries.ConfigFlow): self._host = user_input['host'] return await self.async_step_auth() + async_step_homekit = async_step_zeroconf + async def async_step_import(self, user_input): """Import a config entry.""" for entry in self._async_current_entries(): diff --git a/homeassistant/components/tradfri/manifest.json b/homeassistant/components/tradfri/manifest.json index aba3805a4aa..ba6b21e0028 100644 --- a/homeassistant/components/tradfri/manifest.json +++ b/homeassistant/components/tradfri/manifest.json @@ -6,6 +6,11 @@ "requirements": [ "pytradfri[async]==6.0.1" ], + "homekit": { + "models": [ + "TRADFRI" + ] + }, "dependencies": [], "zeroconf": ["_coap._udp.local."], "codeowners": [ diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index c610c28da39..1902df1060b 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -11,6 +11,11 @@ "Belkin International Inc." ] }, + "homekit": { + "models": [ + "Wemo" + ] + }, "dependencies": [], "codeowners": [ "@sqldiablo" diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index b8967cead3b..79f941d8bcf 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -12,7 +12,7 @@ from homeassistant.util import convert from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN) -from . import SUBSCRIPTION_REGISTRY +from . import SUBSCRIPTION_REGISTRY, DOMAIN as WEMO_DOMAIN SCAN_INTERVAL = timedelta(seconds=10) @@ -93,6 +93,14 @@ class WemoSwitch(SwitchDevice): """Return the name of the switch if any.""" return self._name + @property + def device_info(self): + """Return the device info.""" + return { + 'name': self._name, + 'identifiers': {(WEMO_DOMAIN, self._serialnumber)}, + } + @property def device_state_attributes(self): """Return the state attributes of the device.""" diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 289aba6ef56..6011712c2f9 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -112,7 +112,7 @@ def handle_homekit(hass, info) -> bool: return False for test_model in HOMEKIT: - if not model.startswith(test_model): + if model != test_model and not model.startswith(test_model + " "): continue hass.add_job( diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 024bb89dc99..4e46e9dd366 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -20,5 +20,8 @@ ZEROCONF = { } HOMEKIT = { - "LIFX ": "lifx" + "BSB002": "hue", + "LIFX": "lifx", + "TRADFRI": "tradfri", + "Wemo": "wemo" } diff --git a/script/hassfest/ssdp.py b/script/hassfest/ssdp.py index d9a50dd7c38..3c13da98a9b 100644 --- a/script/hassfest/ssdp.py +++ b/script/hassfest/ssdp.py @@ -44,7 +44,7 @@ def generate_and_validate(integrations: Dict[str, Integration]): try: with open(str(integration.path / "config_flow.py")) as fp: content = fp.read() - if (' async_step_ssdp(' not in content and + if (' async_step_ssdp' not in content and 'register_discovery_flow' not in content): integration.add_error( 'ssdp', 'Config flow has no async_step_ssdp') diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index 25e8da99b55..f30899d5948 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -42,13 +42,13 @@ def generate_and_validate(integrations: Dict[str, Integration]): uses_discovery_flow = 'register_discovery_flow' in content if (service_types and not uses_discovery_flow and - ' async_step_zeroconf(' not in content): + ' 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): + ' async_step_homekit' not in content): integration.add_error( 'zeroconf', 'Config flow has no async_step_homekit') continue @@ -64,9 +64,6 @@ def generate_and_validate(integrations: Dict[str, Integration]): 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', diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index b5f923dd55e..99562f60045 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -283,7 +283,7 @@ async def test_discovery_ignored_model(hass): 'host': '127.0.0.1', 'port': 8080, 'properties': { - 'md': 'BSB002', + 'md': config_flow.HOMEKIT_IGNORE[0], 'id': '00:00:00:00:00:00', 'c#': 1, 'sf': 1, diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index b7736e62390..a4524dfd48d 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -371,3 +371,38 @@ async def test_creating_entry_removes_entries_for_same_host_or_bridge(hass): # We did not process the result of this entry but already removed the old # ones. So we should have 0 entries. assert len(hass.config_entries.async_entries('hue')) == 0 + + +async def test_bridge_homekit(hass): + """Test a bridge being discovered via HomeKit.""" + 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_homekit({ + 'host': '0.0.0.0', + 'serial': '1234', + 'manufacturerURL': config_flow.HUE_MANUFACTURERURL + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_bridge_homekit_already_configured(hass): + """Test if a HomeKit discovered bridge has already been configured.""" + MockConfigEntry(domain='hue', data={ + 'host': '0.0.0.0' + }).add_to_hass(hass) + + flow = config_flow.HueFlowHandler() + flow.hass = hass + flow.context = {} + + result = await flow.async_step_homekit({ + 'host': '0.0.0.0', + }) + + assert result['type'] == 'abort' diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 27c1dc75749..e67d9063b0a 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -31,12 +31,15 @@ def get_service_info_mock(service_type, name): properties={b'macaddress': b'ABCDEF012345'}) -def get_homekit_info_mock(service_type, name): +def get_homekit_info_mock(model): """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'}) + def mock_homekit_info(service_type, name): + return ServiceInfo( + service_type, name, address=b'\n\x00\x00\x14', port=80, weight=0, + priority=0, server='name.local.', + properties={b'md': model.encode()}) + + return mock_homekit_info async def test_setup(hass, mock_zeroconf): @@ -54,7 +57,7 @@ async def test_setup(hass, mock_zeroconf): assert len(mock_config_flow.mock_calls) == len(zc_gen.ZEROCONF) * 2 -async def test_homekit(hass, mock_zeroconf): +async def test_homekit_match_partial(hass, mock_zeroconf): """Test configured options for a device are loaded via config entry.""" with patch.dict( zc_gen.ZEROCONF, { @@ -65,10 +68,32 @@ async def test_homekit(hass, mock_zeroconf): ) 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 + mock_zeroconf.get_service_info.side_effect = \ + get_homekit_info_mock("LIFX bulb") 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' + + +async def test_homekit_match_full(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("BSB002") + 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] == 'hue' From 14066dfb5ac20b1b2e375a6c47e43fdb68426d10 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Jun 2019 23:08:55 -0700 Subject: [PATCH 64/67] Check cloud trusted proxies (#24395) --- homeassistant/components/cloud/const.py | 4 ++ homeassistant/components/cloud/http_api.py | 8 +++- homeassistant/components/cloud/prefs.py | 22 ++++++++- homeassistant/components/http/__init__.py | 1 + tests/components/cloud/test_http_api.py | 53 +++++++++++++++++++++- 5 files changed, 83 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index e2f4b9c0785..65062213a63 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -38,3 +38,7 @@ DISPATCHER_REMOTE_UPDATE = 'cloud_remote_update' class InvalidTrustedNetworks(Exception): """Raised when invalid trusted networks config.""" + + +class InvalidTrustedProxies(Exception): + """Raised when invalid trusted proxies config.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index e6151a917af..9908268b252 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -18,7 +18,8 @@ from homeassistant.components.google_assistant import helpers as google_helpers from .const import ( DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_SECURE_DEVICES_PIN, InvalidTrustedNetworks) + PREF_GOOGLE_SECURE_DEVICES_PIN, InvalidTrustedNetworks, + InvalidTrustedProxies) _LOGGER = logging.getLogger(__name__) @@ -52,7 +53,10 @@ SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ _CLOUD_ERRORS = { InvalidTrustedNetworks: (500, 'Remote UI not compatible with 127.0.0.1/::1' - ' as a trusted network.') + ' as a trusted network.'), + InvalidTrustedProxies: + (500, 'Remote UI not compatible with 127.0.0.1/::1' + ' as trusted proxies.'), } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 0f45f25c49b..9f2579134e5 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -6,7 +6,7 @@ from .const import ( 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) + InvalidTrustedNetworks, InvalidTrustedProxies) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -59,6 +59,9 @@ class CloudPreferences: if remote_enabled is True and self._has_local_trusted_network: raise InvalidTrustedNetworks + if remote_enabled is True and self._has_local_trusted_proxies: + raise InvalidTrustedProxies + await self._store.async_save(self._prefs) async def async_update_google_entity_config( @@ -112,7 +115,7 @@ class CloudPreferences: if not enabled: return False - if self._has_local_trusted_network: + if self._has_local_trusted_network or self._has_local_trusted_proxies: return False return True @@ -162,3 +165,18 @@ class CloudPreferences: return True return False + + @property + def _has_local_trusted_proxies(self) -> bool: + """Return if we allow localhost to be a proxy and use its data.""" + if not hasattr(self._hass, 'http'): + return False + + local4 = ip_address('127.0.0.1') + local6 = ip_address('::1') + + if any(local4 in nwk or local6 in nwk + for nwk in self._hass.http.trusted_proxies): + return True + + return False diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index ad64b38200a..a21fb2ab632 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -228,6 +228,7 @@ class HomeAssistantHTTP: self.ssl_key = ssl_key self.server_host = server_host self.server_port = server_port + self.trusted_proxies = trusted_proxies self.is_ban_enabled = is_ban_enabled self.ssl_profile = ssl_profile self._handler = None diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 5ccaba14be6..24bd647405a 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1,6 +1,7 @@ """Tests for the HTTP API for the cloud component.""" import asyncio from unittest.mock import patch, MagicMock +from ipaddress import ip_network import pytest from jose import jwt @@ -672,7 +673,7 @@ async def test_enabling_remote_trusted_networks_local6( async def test_enabling_remote_trusted_networks_other( hass, hass_ws_client, setup_api, mock_cloud_login): - """Test we cannot enable remote UI when trusted networks active.""" + """Test we can enable remote UI when trusted networks active.""" hass.auth._providers[('trusted_networks', None)] = \ tn_auth.TrustedNetworksAuthProvider( hass, None, tn_auth.CONFIG_SCHEMA({ @@ -749,3 +750,53 @@ async def test_update_google_entity( 'aliases': ['lefty', 'righty'], 'disable_2fa': False, } + + +async def test_enabling_remote_trusted_proxies_local4( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test we cannot enable remote UI when trusted networks active.""" + hass.http.trusted_proxies.append(ip_network('127.0.0.1')) + + client = await hass_ws_client(hass) + + with patch( + 'hass_nabucasa.remote.RemoteUI.connect', + side_effect=AssertionError + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/remote/connect', + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 500 + assert response['error']['message'] == \ + 'Remote UI not compatible with 127.0.0.1/::1 as trusted proxies.' + + assert len(mock_connect.mock_calls) == 0 + + +async def test_enabling_remote_trusted_proxies_local6( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test we cannot enable remote UI when trusted networks active.""" + hass.http.trusted_proxies.append(ip_network('::1')) + + client = await hass_ws_client(hass) + + with patch( + 'hass_nabucasa.remote.RemoteUI.connect', + side_effect=AssertionError + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/remote/connect', + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 500 + assert response['error']['message'] == \ + 'Remote UI not compatible with 127.0.0.1/::1 as trusted proxies.' + + assert len(mock_connect.mock_calls) == 0 From dc93779f02eb450d7eba4f08302ca7a87ae873bf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Jun 2019 23:13:57 -0700 Subject: [PATCH 65/67] Bumped version to 0.94.1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 58897e78d0c..52d13ec58a6 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 94 -PATCH_VERSION = '0' +PATCH_VERSION = '1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From bfafe9ccbe03862de7a5f583ea8b312102d0c39c Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Sat, 8 Jun 2019 14:21:41 +0800 Subject: [PATCH 66/67] Fix for sun issues (#24309) --- homeassistant/components/sun/__init__.py | 21 ++++++++++++++------- tests/components/sun/test_init.py | 10 ++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index dda692a8d80..edb2549164b 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -3,7 +3,8 @@ import logging from datetime import timedelta from homeassistant.const import ( - CONF_ELEVATION, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET) + CONF_ELEVATION, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, + EVENT_CORE_CONFIG_UPDATE) from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time @@ -70,7 +71,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(hass, get_astral_location(hass)) + Sun(hass) return True @@ -79,18 +80,23 @@ class Sun(Entity): entity_id = ENTITY_ID - def __init__(self, hass, location): + def __init__(self, hass): """Initialize the sun.""" self.hass = hass - self.location = location + self.location = None self._state = self.next_rising = self.next_setting = None 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 - self._next_change = None - self.update_events(dt_util.utcnow()) + + def update_location(event): + self.location = get_astral_location(self.hass) + self.update_events(dt_util.utcnow()) + update_location(None) + self.hass.bus.async_listen( + EVENT_CORE_CONFIG_UPDATE, update_location) @property def name(self): @@ -100,7 +106,8 @@ class Sun(Entity): @property def state(self): """Return the state of the sun.""" - if self.next_rising > self.next_setting: + # 0.8333 is the same value as astral uses + if self.solar_elevation > -0.833: return STATE_ABOVE_HORIZON return STATE_BELOW_HORIZON diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py index 374527e2c8a..26d6bd73fed 100644 --- a/tests/components/sun/test_init.py +++ b/tests/components/sun/test_init.py @@ -119,6 +119,14 @@ async def test_state_change(hass): assert sun.STATE_ABOVE_HORIZON == \ hass.states.get(sun.ENTITY_ID).state + with patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=now): + await hass.config.async_update(longitude=hass.config.longitude+90) + await hass.async_block_till_done() + + assert sun.STATE_ABOVE_HORIZON == \ + 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.""" @@ -142,6 +150,8 @@ async def test_norway_in_june(hass): state.attributes[sun.STATE_ATTR_NEXT_SETTING]) == \ datetime(2016, 7, 26, 22, 19, 1, tzinfo=dt_util.UTC) + assert state.state == sun.STATE_ABOVE_HORIZON + @mark.skip async def test_state_change_count(hass): From b68a796c7ce5d33bbf70fb21b7cd0ee91509d451 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 8 Jun 2019 08:43:18 +0200 Subject: [PATCH 67/67] deCONZ - properly identify configured bridge (#24378) --- .../components/deconz/config_flow.py | 22 +++++++++++-------- tests/components/deconz/test_config_flow.py | 13 ++++++++--- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index cf172ad7991..ea93cc590e2 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -18,6 +18,7 @@ from .const import CONF_BRIDGEID, DEFAULT_PORT, DOMAIN DECONZ_MANUFACTURERURL = 'http://www.dresden-elektronik.de' CONF_SERIAL = 'serial' +ATTR_UUID = 'udn' @callback @@ -156,25 +157,28 @@ class DeconzFlowHandler(config_entries.ConfigFlow): if discovery_info[ATTR_MANUFACTURERURL] != DECONZ_MANUFACTURERURL: return self.async_abort(reason='not_deconz_bridge') - bridgeid = discovery_info[ATTR_SERIAL] - gateway_entries = configured_gateways(self.hass) + uuid = discovery_info[ATTR_UUID].replace('uuid:', '') + gateways = { + gateway.api.config.uuid: gateway + for gateway in self.hass.data.get(DOMAIN, {}).values() + } - if bridgeid in gateway_entries: - entry = gateway_entries[bridgeid] + if uuid in gateways: + entry = gateways[uuid].config_entry 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] + bridgeid = discovery_info[ATTR_SERIAL] + if any(bridgeid == flow['context'][CONF_BRIDGEID] for flow in self._async_in_progress()): return self.async_abort(reason='already_in_progress') + # pylint: disable=unsupported-assignment-operation + self.context[CONF_BRIDGEID] = bridgeid + deconz_config = { CONF_HOST: discovery_info[CONF_HOST], CONF_PORT: discovery_info[CONF_PORT], - CONF_BRIDGEID: bridgeid } return await self.async_step_import(deconz_config) diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index 2b9f2c013b0..ac22c964151 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -1,5 +1,5 @@ """Tests for deCONZ config flow.""" -from unittest.mock import patch +from unittest.mock import Mock, patch import asyncio @@ -177,7 +177,8 @@ async def test_bridge_ssdp_discovery(hass): config_flow.CONF_PORT: 80, config_flow.ATTR_SERIAL: 'id', config_flow.ATTR_MANUFACTURERURL: - config_flow.DECONZ_MANUFACTURERURL + config_flow.DECONZ_MANUFACTURERURL, + config_flow.ATTR_UUID: 'uuid:1234' }, context={'source': 'ssdp'} ) @@ -207,13 +208,19 @@ async def test_bridge_discovery_update_existing_entry(hass): }) entry.add_to_hass(hass) + gateway = Mock() + gateway.config_entry = entry + gateway.api.config.uuid = '1234' + hass.data[config_flow.DOMAIN] = {'id': gateway} + result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, data={ config_flow.CONF_HOST: 'mock-deconz', config_flow.ATTR_SERIAL: 'id', config_flow.ATTR_MANUFACTURERURL: - config_flow.DECONZ_MANUFACTURERURL + config_flow.DECONZ_MANUFACTURERURL, + config_flow.ATTR_UUID: 'uuid:1234' }, context={'source': 'ssdp'} )