From fd7fff2ce8d759db9eaa74c4823622471691a0a6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Nov 2018 20:50:11 +0100 Subject: [PATCH 01/47] Version bump to 0.83.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 72fc2165d28..29e01faaa48 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -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 6bcedb3ac5adcf7ff39ff27f034cc2ee6c35a6c4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Nov 2018 14:16:30 +0100 Subject: [PATCH 02/47] Updated frontend to 20181121.1 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 3768a59788e..d8ea057a4f0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181121.0'] +REQUIREMENTS = ['home-assistant-frontend==20181121.1'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 4ddc81686b4..8072940ddbd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -485,7 +485,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181121.0 +home-assistant-frontend==20181121.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ebc180908e..f7223771891 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -97,7 +97,7 @@ hdate==0.7.5 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181121.0 +home-assistant-frontend==20181121.1 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From cf22060c5ed5c7a7fe52c612042f9ed89bedd38b Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Mon, 26 Nov 2018 13:17:56 +0100 Subject: [PATCH 03/47] Use asyncio Lock for fibaro light (#18622) * Use asyncio Lock for fibaro light * line length and empty line at end * async turn_off Turned the turn_off into async as well * bless you, blank lines... My local flake8 lies to me. Not cool. --- homeassistant/components/light/fibaro.py | 144 +++++++++++++---------- 1 file changed, 81 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/light/fibaro.py b/homeassistant/components/light/fibaro.py index cfc28e12218..96069d50335 100644 --- a/homeassistant/components/light/fibaro.py +++ b/homeassistant/components/light/fibaro.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/light.fibaro/ """ import logging -import threading +import asyncio +from functools import partial from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, ENTITY_ID_FORMAT, @@ -37,12 +38,15 @@ def scaleto100(value): return max(0, min(100, ((value * 100.4) / 255.0))) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, + config, + async_add_entities, + discovery_info=None): """Perform the setup for Fibaro controller devices.""" if discovery_info is None: return - add_entities( + async_add_entities( [FibaroLight(device, hass.data[FIBARO_CONTROLLER]) for device in hass.data[FIBARO_DEVICES]['light']], True) @@ -58,7 +62,7 @@ class FibaroLight(FibaroDevice, Light): self._brightness = None self._white = 0 - self._update_lock = threading.RLock() + self._update_lock = asyncio.Lock() if 'levelChange' in fibaro_device.interfaces: self._supported_flags |= SUPPORT_BRIGHTNESS if 'color' in fibaro_device.properties: @@ -88,78 +92,92 @@ class FibaroLight(FibaroDevice, Light): """Flag supported features.""" return self._supported_flags - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the light on.""" - with self._update_lock: - if self._supported_flags & SUPPORT_BRIGHTNESS: - target_brightness = kwargs.get(ATTR_BRIGHTNESS) + async with self._update_lock: + await self.hass.async_add_executor_job( + partial(self._turn_on, **kwargs)) - # No brightness specified, so we either restore it to - # last brightness or switch it on at maximum level - if target_brightness is None: - if self._brightness == 0: - if self._last_brightness: - self._brightness = self._last_brightness - else: - self._brightness = 100 - else: - # We set it to the target brightness and turn it on - self._brightness = scaleto100(target_brightness) + def _turn_on(self, **kwargs): + """Really turn the light on.""" + if self._supported_flags & SUPPORT_BRIGHTNESS: + target_brightness = kwargs.get(ATTR_BRIGHTNESS) - if self._supported_flags & SUPPORT_COLOR: - # Update based on parameters - self._white = kwargs.get(ATTR_WHITE_VALUE, self._white) - self._color = kwargs.get(ATTR_HS_COLOR, self._color) - rgb = color_util.color_hs_to_RGB(*self._color) - self.call_set_color( - int(rgb[0] * self._brightness / 99.0 + 0.5), - int(rgb[1] * self._brightness / 99.0 + 0.5), - int(rgb[2] * self._brightness / 99.0 + 0.5), - int(self._white * self._brightness / 99.0 + - 0.5)) - if self.state == 'off': - self.set_level(int(self._brightness)) - return + # No brightness specified, so we either restore it to + # last brightness or switch it on at maximum level + if target_brightness is None: + if self._brightness == 0: + if self._last_brightness: + self._brightness = self._last_brightness + else: + self._brightness = 100 + else: + # We set it to the target brightness and turn it on + self._brightness = scaleto100(target_brightness) - if self._supported_flags & SUPPORT_BRIGHTNESS: + if self._supported_flags & SUPPORT_COLOR: + # Update based on parameters + self._white = kwargs.get(ATTR_WHITE_VALUE, self._white) + self._color = kwargs.get(ATTR_HS_COLOR, self._color) + rgb = color_util.color_hs_to_RGB(*self._color) + self.call_set_color( + int(rgb[0] * self._brightness / 99.0 + 0.5), + int(rgb[1] * self._brightness / 99.0 + 0.5), + int(rgb[2] * self._brightness / 99.0 + 0.5), + int(self._white * self._brightness / 99.0 + + 0.5)) + if self.state == 'off': self.set_level(int(self._brightness)) - return + return - # The simplest case is left for last. No dimming, just switch on - self.call_turn_on() + if self._supported_flags & SUPPORT_BRIGHTNESS: + self.set_level(int(self._brightness)) + return - def turn_off(self, **kwargs): + # The simplest case is left for last. No dimming, just switch on + self.call_turn_on() + + async def async_turn_off(self, **kwargs): """Turn the light off.""" + async with self._update_lock: + await self.hass.async_add_executor_job( + partial(self._turn_off, **kwargs)) + + def _turn_off(self, **kwargs): + """Really turn the light off.""" # Let's save the last brightness level before we switch it off - with self._update_lock: - if (self._supported_flags & SUPPORT_BRIGHTNESS) and \ - self._brightness and self._brightness > 0: - self._last_brightness = self._brightness - self._brightness = 0 - self.call_turn_off() + if (self._supported_flags & SUPPORT_BRIGHTNESS) and \ + self._brightness and self._brightness > 0: + self._last_brightness = self._brightness + self._brightness = 0 + self.call_turn_off() @property def is_on(self): """Return true if device is on.""" return self.current_binary_state - def update(self): - """Call to update state.""" + async def async_update(self): + """Update the state.""" + async with self._update_lock: + await self.hass.async_add_executor_job(self._update) + + def _update(self): + """Really update the state.""" # Brightness handling - with self._update_lock: - if self._supported_flags & SUPPORT_BRIGHTNESS: - self._brightness = float(self.fibaro_device.properties.value) - # Color handling - if self._supported_flags & SUPPORT_COLOR: - # Fibaro communicates the color as an 'R, G, B, W' string - rgbw_s = self.fibaro_device.properties.color - if rgbw_s == '0,0,0,0' and\ - 'lastColorSet' in self.fibaro_device.properties: - rgbw_s = self.fibaro_device.properties.lastColorSet - rgbw_list = [int(i) for i in rgbw_s.split(",")][:4] - if rgbw_list[0] or rgbw_list[1] or rgbw_list[2]: - self._color = color_util.color_RGB_to_hs(*rgbw_list[:3]) - if (self._supported_flags & SUPPORT_WHITE_VALUE) and \ - self.brightness != 0: - self._white = min(255, max(0, rgbw_list[3]*100.0 / - self._brightness)) + if self._supported_flags & SUPPORT_BRIGHTNESS: + self._brightness = float(self.fibaro_device.properties.value) + # Color handling + if self._supported_flags & SUPPORT_COLOR: + # Fibaro communicates the color as an 'R, G, B, W' string + rgbw_s = self.fibaro_device.properties.color + if rgbw_s == '0,0,0,0' and\ + 'lastColorSet' in self.fibaro_device.properties: + rgbw_s = self.fibaro_device.properties.lastColorSet + rgbw_list = [int(i) for i in rgbw_s.split(",")][:4] + if rgbw_list[0] or rgbw_list[1] or rgbw_list[2]: + self._color = color_util.color_RGB_to_hs(*rgbw_list[:3]) + if (self._supported_flags & SUPPORT_WHITE_VALUE) and \ + self.brightness != 0: + self._white = min(255, max(0, rgbw_list[3]*100.0 / + self._brightness)) From 2f581b1a1ea17a69db0c20223a9527c503dbeab1 Mon Sep 17 00:00:00 2001 From: Eliseo Martelli Date: Fri, 23 Nov 2018 01:46:22 +0100 Subject: [PATCH 04/47] fixed wording that may confuse user (#18628) --- homeassistant/components/recorder/migration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index a6a6ed46174..825f402aef2 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -29,7 +29,7 @@ def migrate_schema(instance): with open(progress_path, 'w'): pass - _LOGGER.warning("Database requires upgrade. Schema version: %s", + _LOGGER.warning("Database is about to upgrade. Schema version: %s", current_version) if current_version is None: From bb75a39cf165a88bd2cd72d4289408c53685f85e Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 22 Nov 2018 16:43:10 +0100 Subject: [PATCH 05/47] Updated webhook_register, version bump pypoint (#18635) * Updated webhook_register, version bump pypoint * A binary_sensor should be a BinarySensorDevice --- homeassistant/components/binary_sensor/point.py | 3 ++- homeassistant/components/point/__init__.py | 6 +++--- requirements_all.txt | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/binary_sensor/point.py b/homeassistant/components/binary_sensor/point.py index a2ed9eabebf..90a8b0b5813 100644 --- a/homeassistant/components/binary_sensor/point.py +++ b/homeassistant/components/binary_sensor/point.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/binary_sensor.point/ import logging +from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.point import MinutPointEntity from homeassistant.components.point.const import ( DOMAIN as POINT_DOMAIN, NEW_DEVICE, SIGNAL_WEBHOOK) @@ -45,7 +46,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for device_class in EVENTS), True) -class MinutPointBinarySensor(MinutPointEntity): +class MinutPointBinarySensor(MinutPointEntity, BinarySensorDevice): """The platform class required by Home Assistant.""" def __init__(self, point_client, device_id, device_class): diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index fcbd5ddb064..36215da7893 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -25,7 +25,7 @@ from .const import ( CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, NEW_DEVICE, SCAN_INTERVAL, SIGNAL_UPDATE_ENTITY, SIGNAL_WEBHOOK) -REQUIREMENTS = ['pypoint==1.0.5'] +REQUIREMENTS = ['pypoint==1.0.6'] DEPENDENCIES = ['webhook'] _LOGGER = logging.getLogger(__name__) @@ -113,8 +113,8 @@ async def async_setup_webhook(hass: HomeAssistantType, entry: ConfigEntry, session.update_webhook(entry.data[CONF_WEBHOOK_URL], entry.data[CONF_WEBHOOK_ID]) - hass.components.webhook.async_register(entry.data[CONF_WEBHOOK_ID], - handle_webhook) + hass.components.webhook.async_register( + DOMAIN, 'Point', entry.data[CONF_WEBHOOK_ID], handle_webhook) async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): diff --git a/requirements_all.txt b/requirements_all.txt index 8072940ddbd..bc53dbce24e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1099,7 +1099,7 @@ pyowm==2.9.0 pypjlink2==1.2.0 # homeassistant.components.point -pypoint==1.0.5 +pypoint==1.0.6 # homeassistant.components.sensor.pollen pypollencom==2.2.2 From 56c7c8ccc514e8b26c3dc443137acc234fbf9319 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 22 Nov 2018 12:48:50 +0100 Subject: [PATCH 06/47] Fix vol Dict -> dict (#18637) --- homeassistant/components/lovelace/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 39644bd047b..5234dbaf29d 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -63,7 +63,7 @@ SCHEMA_GET_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ SCHEMA_UPDATE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_UPDATE_CARD, vol.Required('card_id'): str, - vol.Required('card_config'): vol.Any(str, Dict), + vol.Required('card_config'): vol.Any(str, dict), vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), }) @@ -71,7 +71,7 @@ SCHEMA_UPDATE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ SCHEMA_ADD_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_ADD_CARD, vol.Required('view_id'): str, - vol.Required('card_config'): vol.Any(str, Dict), + vol.Required('card_config'): vol.Any(str, dict), vol.Optional('position'): int, vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), @@ -99,14 +99,14 @@ SCHEMA_GET_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ SCHEMA_UPDATE_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_UPDATE_VIEW, vol.Required('view_id'): str, - vol.Required('view_config'): vol.Any(str, Dict), + vol.Required('view_config'): vol.Any(str, dict), vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), }) SCHEMA_ADD_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_ADD_VIEW, - vol.Required('view_config'): vol.Any(str, Dict), + vol.Required('view_config'): vol.Any(str, dict), vol.Optional('position'): int, vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), From c3b76b40f6b4fa4433bde27472a370412053f24a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 25 Nov 2018 12:30:38 +0100 Subject: [PATCH 07/47] Set correct default offset (#18678) --- homeassistant/components/sensor/ruter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/ruter.py b/homeassistant/components/sensor/ruter.py index ddad6a43c75..7b02b51d0c0 100644 --- a/homeassistant/components/sensor/ruter.py +++ b/homeassistant/components/sensor/ruter.py @@ -28,7 +28,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_STOP_ID): cv.positive_int, vol.Optional(CONF_DESTINATION): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_OFFSET, default=1): cv.positive_int, + vol.Optional(CONF_OFFSET, default=0): cv.positive_int, }) From f9f71c4a6dc7dfdfb156a2f4f96970cb895262e3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Nov 2018 14:20:56 +0100 Subject: [PATCH 08/47] Bumped version to 0.83.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 29e01faaa48..9866bb5dad9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -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 3a8303137a6151c74fc6f8236f7f7f3f9766b2f2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 25 Nov 2018 18:04:48 +0100 Subject: [PATCH 09/47] Add permission checks to Rest API (#18639) * Add permission checks to Rest API * Clean up unnecessary method * Remove all the tuple stuff from entity check * Simplify perms * Correct param name for owner permission * Hass.io make/update user to be admin * Types --- homeassistant/auth/__init__.py | 17 +++- homeassistant/auth/auth_store.py | 27 +++++++ homeassistant/auth/models.py | 16 +++- homeassistant/auth/permissions/__init__.py | 61 +++++---------- homeassistant/auth/permissions/entities.py | 40 +++++----- homeassistant/components/api.py | 27 ++++++- homeassistant/components/hassio/__init__.py | 9 ++- homeassistant/components/http/view.py | 10 ++- homeassistant/helpers/service.py | 10 +-- tests/auth/permissions/test_entities.py | 50 ++++++------ tests/auth/permissions/test_init.py | 34 -------- tests/common.py | 7 +- tests/components/conftest.py | 5 +- tests/components/hassio/test_init.py | 28 +++++++ tests/components/test_api.py | 86 +++++++++++++++++++-- 15 files changed, 282 insertions(+), 145 deletions(-) delete mode 100644 tests/auth/permissions/test_init.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index e69dec37df2..7d8ef13d2bb 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -132,13 +132,15 @@ class AuthManager: return None - async def async_create_system_user(self, name: str) -> models.User: + async def async_create_system_user( + self, name: str, + group_ids: Optional[List[str]] = None) -> models.User: """Create a system user.""" user = await self._store.async_create_user( name=name, system_generated=True, is_active=True, - group_ids=[], + group_ids=group_ids or [], ) self.hass.bus.async_fire(EVENT_USER_ADDED, { @@ -217,6 +219,17 @@ class AuthManager: 'user_id': user.id }) + async def async_update_user(self, user: models.User, + name: Optional[str] = None, + group_ids: Optional[List[str]] = None) -> None: + """Update a user.""" + kwargs = {} # type: Dict[str,Any] + if name is not None: + kwargs['name'] = name + if group_ids is not None: + kwargs['group_ids'] = group_ids + await self._store.async_update_user(user, **kwargs) + async def async_activate_user(self, user: models.User) -> None: """Activate a user.""" await self._store.async_activate_user(user) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 867d5357a58..cf82c40a4d3 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -133,6 +133,33 @@ class AuthStore: self._users.pop(user.id) self._async_schedule_save() + async def async_update_user( + self, user: models.User, name: Optional[str] = None, + is_active: Optional[bool] = None, + group_ids: Optional[List[str]] = None) -> None: + """Update a user.""" + assert self._groups is not None + + if group_ids is not None: + groups = [] + for grid in group_ids: + group = self._groups.get(grid) + if group is None: + raise ValueError("Invalid group specified.") + groups.append(group) + + user.groups = groups + user.invalidate_permission_cache() + + for attr_name, value in ( + ('name', name), + ('is_active', is_active), + ): + if value is not None: + setattr(user, attr_name, value) + + self._async_schedule_save() + async def async_activate_user(self, user: models.User) -> None: """Activate a user.""" user.is_active = True diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index cefaabe7521..4b192c35898 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -8,6 +8,7 @@ import attr from homeassistant.util import dt as dt_util from . import permissions as perm_mdl +from .const import GROUP_ID_ADMIN from .util import generate_secret TOKEN_TYPE_NORMAL = 'normal' @@ -48,7 +49,7 @@ class User: ) # type: Dict[str, RefreshToken] _permissions = attr.ib( - type=perm_mdl.PolicyPermissions, + type=Optional[perm_mdl.PolicyPermissions], init=False, cmp=False, default=None, @@ -69,6 +70,19 @@ class User: return self._permissions + @property + def is_admin(self) -> bool: + """Return if user is part of the admin group.""" + if self.is_owner: + return True + + return self.is_active and any( + gr.id == GROUP_ID_ADMIN for gr in self.groups) + + def invalidate_permission_cache(self) -> None: + """Invalidate permission cache.""" + self._permissions = None + @attr.s(slots=True) class RefreshToken: diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index fd3cf81f029..9113f2b03a9 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -5,10 +5,8 @@ from typing import ( # noqa: F401 import voluptuous as vol -from homeassistant.core import State - from .const import CAT_ENTITIES -from .types import CategoryType, PolicyType +from .types import PolicyType from .entities import ENTITY_POLICY_SCHEMA, compile_entities from .merge import merge_policies # noqa @@ -22,13 +20,20 @@ _LOGGER = logging.getLogger(__name__) class AbstractPermissions: """Default permissions class.""" - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" + _cached_entity_func = None + + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" raise NotImplementedError - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" - raise NotImplementedError + def check_entity(self, entity_id: str, key: str) -> bool: + """Check if we can access entity.""" + entity_func = self._cached_entity_func + + if entity_func is None: + entity_func = self._cached_entity_func = self._entity_func() + + return entity_func(entity_id, key) class PolicyPermissions(AbstractPermissions): @@ -37,34 +42,10 @@ class PolicyPermissions(AbstractPermissions): def __init__(self, policy: PolicyType) -> None: """Initialize the permission class.""" self._policy = policy - self._compiled = {} # type: Dict[str, Callable[..., bool]] - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" - func = self._policy_func(CAT_ENTITIES, compile_entities) - return func(entity_id, (key,)) - - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" - func = self._policy_func(CAT_ENTITIES, compile_entities) - keys = ('read',) - return [entity for entity in states if func(entity.entity_id, keys)] - - def _policy_func(self, category: str, - compile_func: Callable[[CategoryType], Callable]) \ - -> Callable[..., bool]: - """Get a policy function.""" - func = self._compiled.get(category) - - if func: - return func - - func = self._compiled[category] = compile_func( - self._policy.get(category)) - - _LOGGER.debug("Compiled %s func: %s", category, func) - - return func + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + return compile_entities(self._policy.get(CAT_ENTITIES)) def __eq__(self, other: Any) -> bool: """Equals check.""" @@ -78,13 +59,9 @@ class _OwnerPermissions(AbstractPermissions): # pylint: disable=no-self-use - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" - return True - - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" - return states + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + return lambda entity_id, key: True OwnerPermissions = _OwnerPermissions() # pylint: disable=invalid-name diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index 89b9398628c..74a43246fd1 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -28,28 +28,28 @@ ENTITY_POLICY_SCHEMA = vol.Any(True, vol.Schema({ })) -def _entity_allowed(schema: ValueType, keys: Tuple[str]) \ +def _entity_allowed(schema: ValueType, key: str) \ -> Union[bool, None]: """Test if an entity is allowed based on the keys.""" if schema is None or isinstance(schema, bool): return schema assert isinstance(schema, dict) - return schema.get(keys[0]) + return schema.get(key) def compile_entities(policy: CategoryType) \ - -> Callable[[str, Tuple[str]], bool]: + -> Callable[[str, str], bool]: """Compile policy into a function that tests policy.""" # None, Empty Dict, False if not policy: - def apply_policy_deny_all(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_deny_all(entity_id: str, key: str) -> bool: """Decline all.""" return False return apply_policy_deny_all if policy is True: - def apply_policy_allow_all(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_allow_all(entity_id: str, key: str) -> bool: """Approve all.""" return True @@ -61,7 +61,7 @@ def compile_entities(policy: CategoryType) \ entity_ids = policy.get(ENTITY_ENTITY_IDS) all_entities = policy.get(SUBCAT_ALL) - funcs = [] # type: List[Callable[[str, Tuple[str]], Union[None, bool]]] + funcs = [] # type: List[Callable[[str, str], Union[None, bool]]] # The order of these functions matter. The more precise are at the top. # If a function returns None, they cannot handle it. @@ -70,23 +70,23 @@ def compile_entities(policy: CategoryType) \ # Setting entity_ids to a boolean is final decision for permissions # So return right away. if isinstance(entity_ids, bool): - def allowed_entity_id_bool(entity_id: str, keys: Tuple[str]) -> bool: + def allowed_entity_id_bool(entity_id: str, key: str) -> bool: """Test if allowed entity_id.""" return entity_ids # type: ignore return allowed_entity_id_bool if entity_ids is not None: - def allowed_entity_id_dict(entity_id: str, keys: Tuple[str]) \ + def allowed_entity_id_dict(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed entity_id.""" return _entity_allowed( - entity_ids.get(entity_id), keys) # type: ignore + entity_ids.get(entity_id), key) # type: ignore funcs.append(allowed_entity_id_dict) if isinstance(domains, bool): - def allowed_domain_bool(entity_id: str, keys: Tuple[str]) \ + def allowed_domain_bool(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" return domains @@ -94,31 +94,31 @@ def compile_entities(policy: CategoryType) \ funcs.append(allowed_domain_bool) elif domains is not None: - def allowed_domain_dict(entity_id: str, keys: Tuple[str]) \ + def allowed_domain_dict(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" domain = entity_id.split(".", 1)[0] - return _entity_allowed(domains.get(domain), keys) # type: ignore + return _entity_allowed(domains.get(domain), key) # type: ignore funcs.append(allowed_domain_dict) if isinstance(all_entities, bool): - def allowed_all_entities_bool(entity_id: str, keys: Tuple[str]) \ + def allowed_all_entities_bool(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" return all_entities funcs.append(allowed_all_entities_bool) elif all_entities is not None: - def allowed_all_entities_dict(entity_id: str, keys: Tuple[str]) \ + def allowed_all_entities_dict(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" - return _entity_allowed(all_entities, keys) + return _entity_allowed(all_entities, key) funcs.append(allowed_all_entities_dict) # Can happen if no valid subcategories specified if not funcs: - def apply_policy_deny_all_2(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_deny_all_2(entity_id: str, key: str) -> bool: """Decline all.""" return False @@ -128,16 +128,16 @@ def compile_entities(policy: CategoryType) \ func = funcs[0] @wraps(func) - def apply_policy_func(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_func(entity_id: str, key: str) -> bool: """Apply a single policy function.""" - return func(entity_id, keys) is True + return func(entity_id, key) is True return apply_policy_func - def apply_policy_funcs(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_funcs(entity_id: str, key: str) -> bool: """Apply several policy functions.""" for func in funcs: - result = func(entity_id, keys) + result = func(entity_id, key) if result is not None: return result return False diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index cbe404537eb..b001bcd0437 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -20,7 +20,8 @@ from homeassistant.const import ( URL_API_SERVICES, URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, URL_API_TEMPLATE, __version__) import homeassistant.core as ha -from homeassistant.exceptions import TemplateError +from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.exceptions import TemplateError, Unauthorized from homeassistant.helpers import template from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.state import AsyncTrackStates @@ -81,6 +82,8 @@ class APIEventStream(HomeAssistantView): async def get(self, request): """Provide a streaming interface for the event bus.""" + if not request['hass_user'].is_admin: + raise Unauthorized() hass = request.app['hass'] stop_obj = object() to_write = asyncio.Queue(loop=hass.loop) @@ -185,7 +188,13 @@ class APIStatesView(HomeAssistantView): @ha.callback def get(self, request): """Get current states.""" - return self.json(request.app['hass'].states.async_all()) + user = request['hass_user'] + entity_perm = user.permissions.check_entity + states = [ + state for state in request.app['hass'].states.async_all() + if entity_perm(state.entity_id, 'read') + ] + return self.json(states) class APIEntityStateView(HomeAssistantView): @@ -197,6 +206,10 @@ class APIEntityStateView(HomeAssistantView): @ha.callback def get(self, request, entity_id): """Retrieve state of entity.""" + user = request['hass_user'] + if not user.permissions.check_entity(entity_id, POLICY_READ): + raise Unauthorized(entity_id=entity_id) + state = request.app['hass'].states.get(entity_id) if state: return self.json(state) @@ -204,6 +217,8 @@ class APIEntityStateView(HomeAssistantView): async def post(self, request, entity_id): """Update state of entity.""" + if not request['hass_user'].is_admin: + raise Unauthorized(entity_id=entity_id) hass = request.app['hass'] try: data = await request.json() @@ -236,6 +251,8 @@ class APIEntityStateView(HomeAssistantView): @ha.callback def delete(self, request, entity_id): """Remove entity.""" + if not request['hass_user'].is_admin: + raise Unauthorized(entity_id=entity_id) if request.app['hass'].states.async_remove(entity_id): return self.json_message("Entity removed.") return self.json_message("Entity not found.", HTTP_NOT_FOUND) @@ -261,6 +278,8 @@ class APIEventView(HomeAssistantView): async def post(self, request, event_type): """Fire events.""" + if not request['hass_user'].is_admin: + raise Unauthorized() body = await request.text() try: event_data = json.loads(body) if body else None @@ -346,6 +365,8 @@ class APITemplateView(HomeAssistantView): async def post(self, request): """Render a template.""" + if not request['hass_user'].is_admin: + raise Unauthorized() try: data = await request.json() tpl = template.Template(data['template'], request.app['hass']) @@ -363,6 +384,8 @@ class APIErrorLog(HomeAssistantView): async def get(self, request): """Retrieve API error log.""" + if not request['hass_user'].is_admin: + raise Unauthorized() return web.FileResponse(request.app['hass'].data[DATA_LOGGING]) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 4c13cb799a6..6bfcaaa5d85 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -10,6 +10,7 @@ import os import voluptuous as vol +from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import SERVICE_CHECK_CONFIG from homeassistant.const import ( ATTR_NAME, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) @@ -181,8 +182,14 @@ async def async_setup(hass, config): if user and user.refresh_tokens: refresh_token = list(user.refresh_tokens.values())[0] + # Migrate old hass.io users to be admin. + if not user.is_admin: + await hass.auth.async_update_user( + user, group_ids=[GROUP_ID_ADMIN]) + if refresh_token is None: - user = await hass.auth.async_create_system_user('Hass.io') + user = await hass.auth.async_create_system_user( + 'Hass.io', [GROUP_ID_ADMIN]) refresh_token = await hass.auth.async_create_refresh_token(user) data['hassio_user'] = user.id await store.async_save(data) diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index b3b2587fc45..30d4ed0ab8d 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -14,6 +14,7 @@ from aiohttp.web_exceptions import HTTPUnauthorized, HTTPInternalServerError from homeassistant.components.http.ban import process_success_login from homeassistant.core import Context, is_callback from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant import exceptions from homeassistant.helpers.json import JSONEncoder from .const import KEY_AUTHENTICATED, KEY_REAL_IP @@ -107,10 +108,13 @@ def request_handler_factory(view, handler): _LOGGER.info('Serving %s to %s (auth: %s)', request.path, request.get(KEY_REAL_IP), authenticated) - result = handler(request, **request.match_info) + try: + result = handler(request, **request.match_info) - if asyncio.iscoroutine(result): - result = await result + if asyncio.iscoroutine(result): + result = await result + except exceptions.Unauthorized: + raise HTTPUnauthorized() if isinstance(result, web.StreamResponse): # The method handler returned a ready-made Response, how nice of it diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 5e0d9c7e88a..e8068f57286 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -192,9 +192,9 @@ async def entity_service_call(hass, platforms, func, call): user = await hass.auth.async_get_user(call.context.user_id) if user is None: raise UnknownUser(context=call.context) - perms = user.permissions + entity_perms = user.permissions.check_entity else: - perms = None + entity_perms = None # Are we trying to target all entities target_all_entities = ATTR_ENTITY_ID not in call.data @@ -218,7 +218,7 @@ async def entity_service_call(hass, platforms, func, call): # the service on. platforms_entities = [] - if perms is None: + if entity_perms is None: for platform in platforms: if target_all_entities: platforms_entities.append(list(platform.entities.values())) @@ -234,7 +234,7 @@ async def entity_service_call(hass, platforms, func, call): for platform in platforms: platforms_entities.append([ entity for entity in platform.entities.values() - if perms.check_entity(entity.entity_id, POLICY_CONTROL)]) + if entity_perms(entity.entity_id, POLICY_CONTROL)]) else: for platform in platforms: @@ -243,7 +243,7 @@ async def entity_service_call(hass, platforms, func, call): if entity.entity_id not in entity_ids: continue - if not perms.check_entity(entity.entity_id, POLICY_CONTROL): + if not entity_perms(entity.entity_id, POLICY_CONTROL): raise Unauthorized( context=call.context, entity_id=entity.entity_id, diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index 33c164d12b4..40de5ca7334 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -10,7 +10,7 @@ def test_entities_none(): """Test entity ID policy.""" policy = None compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is False def test_entities_empty(): @@ -18,7 +18,7 @@ def test_entities_empty(): policy = {} ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is False def test_entities_false(): @@ -33,7 +33,7 @@ def test_entities_true(): policy = True ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True def test_entities_domains_true(): @@ -43,7 +43,7 @@ def test_entities_domains_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True def test_entities_domains_domain_true(): @@ -55,8 +55,8 @@ def test_entities_domains_domain_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('switch.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('switch.kitchen', 'read') is False def test_entities_domains_domain_false(): @@ -77,7 +77,7 @@ def test_entities_entity_ids_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True def test_entities_entity_ids_false(): @@ -98,8 +98,8 @@ def test_entities_entity_ids_entity_id_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('switch.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('switch.kitchen', 'read') is False def test_entities_entity_ids_entity_id_false(): @@ -124,9 +124,9 @@ def test_entities_control_only(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is False - assert compiled('light.kitchen', ('edit',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is False + assert compiled('light.kitchen', 'edit') is False def test_entities_read_control(): @@ -141,9 +141,9 @@ def test_entities_read_control(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is True - assert compiled('light.kitchen', ('edit',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is True + assert compiled('light.kitchen', 'edit') is False def test_entities_all_allow(): @@ -153,9 +153,9 @@ def test_entities_all_allow(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is True - assert compiled('switch.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is True + assert compiled('switch.kitchen', 'read') is True def test_entities_all_read(): @@ -167,9 +167,9 @@ def test_entities_all_read(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is False - assert compiled('switch.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is False + assert compiled('switch.kitchen', 'read') is True def test_entities_all_control(): @@ -181,7 +181,7 @@ def test_entities_all_control(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is False - assert compiled('light.kitchen', ('control',)) is True - assert compiled('switch.kitchen', ('read',)) is False - assert compiled('switch.kitchen', ('control',)) is True + assert compiled('light.kitchen', 'read') is False + assert compiled('light.kitchen', 'control') is True + assert compiled('switch.kitchen', 'read') is False + assert compiled('switch.kitchen', 'control') is True diff --git a/tests/auth/permissions/test_init.py b/tests/auth/permissions/test_init.py deleted file mode 100644 index fdc5440a9d5..00000000000 --- a/tests/auth/permissions/test_init.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Tests for the auth permission system.""" -from homeassistant.core import State -from homeassistant.auth import permissions - - -def test_policy_perm_filter_states(): - """Test filtering entitites.""" - states = [ - State('light.kitchen', 'on'), - State('light.living_room', 'off'), - State('light.balcony', 'on'), - ] - perm = permissions.PolicyPermissions({ - 'entities': { - 'entity_ids': { - 'light.kitchen': True, - 'light.balcony': True, - } - } - }) - filtered = perm.filter_states(states) - assert len(filtered) == 2 - assert filtered == [states[0], states[2]] - - -def test_owner_permissions(): - """Test owner permissions access all.""" - assert permissions.OwnerPermissions.check_entity('light.kitchen', 'write') - states = [ - State('light.kitchen', 'on'), - State('light.living_room', 'off'), - State('light.balcony', 'on'), - ] - assert permissions.OwnerPermissions.filter_states(states) == states diff --git a/tests/common.py b/tests/common.py index c6a75fcb63d..d5056e220f0 100644 --- a/tests/common.py +++ b/tests/common.py @@ -14,7 +14,8 @@ from contextlib import contextmanager from homeassistant import auth, core as ha, config_entries from homeassistant.auth import ( - models as auth_models, auth_store, providers as auth_providers) + models as auth_models, auth_store, providers as auth_providers, + permissions as auth_permissions) from homeassistant.auth.permissions import system_policies from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config @@ -400,6 +401,10 @@ class MockUser(auth_models.User): auth_mgr._store._users[self.id] = self return self + def mock_policy(self, policy): + """Mock a policy for a user.""" + self._permissions = auth_permissions.PolicyPermissions(policy) + async def register_auth_provider(hass, config): """Register an auth provider.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 2568a109244..97f2044baea 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -72,11 +72,10 @@ def hass_ws_client(aiohttp_client): @pytest.fixture -def hass_access_token(hass): +def hass_access_token(hass, hass_admin_user): """Return an access token to access Home Assistant.""" - user = MockUser().add_to_hass(hass) refresh_token = hass.loop.run_until_complete( - hass.auth.async_create_refresh_token(user, CLIENT_ID)) + hass.auth.async_create_refresh_token(hass_admin_user, CLIENT_ID)) yield hass.auth.async_create_access_token(refresh_token) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 4fd59dd3f7a..51fca931faa 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import patch, Mock import pytest +from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.setup import async_setup_component from homeassistant.components.hassio import ( STORAGE_KEY, async_check_config) @@ -106,6 +107,8 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, ) assert hassio_user is not None assert hassio_user.system_generated + assert len(hassio_user.groups) == 1 + assert hassio_user.groups[0].id == GROUP_ID_ADMIN for token in hassio_user.refresh_tokens.values(): if token.token == refresh_token: break @@ -113,6 +116,31 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, assert False, 'refresh token not found' +async def test_setup_adds_admin_group_to_user(hass, aioclient_mock, + hass_storage): + """Test setup with API push default data.""" + # Create user without admin + user = await hass.auth.async_create_system_user('Hass.io') + assert not user.is_admin + await hass.auth.async_create_refresh_token(user) + + hass_storage[STORAGE_KEY] = { + 'data': {'hassio_user': user.id}, + 'key': STORAGE_KEY, + 'version': 1 + } + + with patch.dict(os.environ, MOCK_ENVIRON), \ + patch('homeassistant.auth.AuthManager.active', return_value=True): + result = await async_setup_component(hass, 'hassio', { + 'http': {}, + 'hassio': {} + }) + assert result + + assert user.is_admin + + async def test_setup_api_push_api_data_no_auth(hass, aioclient_mock, hass_storage): """Test setup with API push default data.""" diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 6f6b4e93068..3ebfa05a3d3 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -16,10 +16,12 @@ from tests.common import async_mock_service @pytest.fixture -def mock_api_client(hass, aiohttp_client): - """Start the Hass HTTP component.""" +def mock_api_client(hass, aiohttp_client, hass_access_token): + """Start the Hass HTTP component and return admin API client.""" hass.loop.run_until_complete(async_setup_component(hass, 'api', {})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app, headers={ + 'Authorization': 'Bearer {}'.format(hass_access_token) + })) @asyncio.coroutine @@ -405,7 +407,8 @@ def _listen_count(hass): return sum(hass.bus.async_listeners().values()) -async def test_api_error_log(hass, aiohttp_client): +async def test_api_error_log(hass, aiohttp_client, hass_access_token, + hass_admin_user): """Test if we can fetch the error log.""" hass.data[DATA_LOGGING] = '/some/path' await async_setup_component(hass, 'api', { @@ -416,7 +419,7 @@ async def test_api_error_log(hass, aiohttp_client): client = await aiohttp_client(hass.http.app) resp = await client.get(const.URL_API_ERROR_LOG) - # Verufy auth required + # Verify auth required assert resp.status == 401 with patch( @@ -424,7 +427,7 @@ async def test_api_error_log(hass, aiohttp_client): return_value=web.Response(status=200, text='Hello') ) as mock_file: resp = await client.get(const.URL_API_ERROR_LOG, headers={ - 'x-ha-access': 'yolo' + 'Authorization': 'Bearer {}'.format(hass_access_token) }) assert len(mock_file.mock_calls) == 1 @@ -432,6 +435,13 @@ async def test_api_error_log(hass, aiohttp_client): assert resp.status == 200 assert await resp.text() == 'Hello' + # Verify we require admin user + hass_admin_user.groups = [] + resp = await client.get(const.URL_API_ERROR_LOG, headers={ + 'Authorization': 'Bearer {}'.format(hass_access_token) + }) + assert resp.status == 401 + async def test_api_fire_event_context(hass, mock_api_client, hass_access_token): @@ -494,3 +504,67 @@ async def test_api_set_state_context(hass, mock_api_client, hass_access_token): state = hass.states.get('light.kitchen') assert state.context.user_id == refresh_token.user.id + + +async def test_event_stream_requires_admin(hass, mock_api_client, + hass_admin_user): + """Test user needs to be admin to access event stream.""" + hass_admin_user.groups = [] + resp = await mock_api_client.get('/api/stream') + assert resp.status == 401 + + +async def test_states_view_filters(hass, mock_api_client, hass_admin_user): + """Test filtering only visible states.""" + hass_admin_user.mock_policy({ + 'entities': { + 'entity_ids': { + 'test.entity': True + } + } + }) + hass.states.async_set('test.entity', 'hello') + hass.states.async_set('test.not_visible_entity', 'invisible') + resp = await mock_api_client.get(const.URL_API_STATES) + assert resp.status == 200 + json = await resp.json() + assert len(json) == 1 + assert json[0]['entity_id'] == 'test.entity' + + +async def test_get_entity_state_read_perm(hass, mock_api_client, + hass_admin_user): + """Test getting a state requires read permission.""" + hass_admin_user.mock_policy({}) + resp = await mock_api_client.get('/api/states/light.test') + assert resp.status == 401 + + +async def test_post_entity_state_admin(hass, mock_api_client, hass_admin_user): + """Test updating state requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.post('/api/states/light.test') + assert resp.status == 401 + + +async def test_delete_entity_state_admin(hass, mock_api_client, + hass_admin_user): + """Test deleting entity requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.delete('/api/states/light.test') + assert resp.status == 401 + + +async def test_post_event_admin(hass, mock_api_client, hass_admin_user): + """Test sending event requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.post('/api/events/state_changed') + assert resp.status == 401 + + +async def test_rendering_template_admin(hass, mock_api_client, + hass_admin_user): + """Test rendering a template requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.post('/api/template') + assert resp.status == 401 From 775c909a8c80442280f5b78efc21655bd95a672c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 27 Nov 2018 20:15:57 +0100 Subject: [PATCH 10/47] Bumped version to 0.83.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9866bb5dad9..9fc6d61cb33 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -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 f3047b9c031cd5b6e373d0639d613cb5de2d5fe5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Nov 2018 19:53:24 +0100 Subject: [PATCH 11/47] Fix logbook filtering entities (#18721) * Fix logbook filtering entities * Fix flaky test --- homeassistant/components/logbook.py | 6 +++--- tests/components/test_logbook.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index ada8bf78ab0..c7a37411f1e 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -391,9 +391,9 @@ def _get_events(hass, config, start_day, end_day, entity_id=None): .filter(Events.event_type.in_(ALL_EVENT_TYPES)) \ .filter((Events.time_fired > start_day) & (Events.time_fired < end_day)) \ - .filter((States.last_updated == States.last_changed) - | (States.state_id.is_(None))) \ - .filter(States.entity_id.in_(entity_ids)) + .filter(((States.last_updated == States.last_changed) & + States.entity_id.in_(entity_ids)) + | (States.state_id.is_(None))) events = execute(query) diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 5229d34b74c..ae1e3d1d51a 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -62,6 +62,12 @@ class TestComponentLogbook(unittest.TestCase): # Our service call will unblock when the event listeners have been # scheduled. This means that they may not have been processed yet. self.hass.block_till_done() + self.hass.data[recorder.DATA_INSTANCE].block_till_done() + + events = list(logbook._get_events( + self.hass, {}, dt_util.utcnow() - timedelta(hours=1), + dt_util.utcnow() + timedelta(hours=1))) + assert len(events) == 2 assert 1 == len(calls) last_call = calls[-1] From 58e0ff0b1b4a4a1177a837f8b9a70831d17ff305 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 24 Nov 2018 14:34:36 -0500 Subject: [PATCH 12/47] Async tests for owntracks device tracker (#18681) --- .../device_tracker/test_owntracks.py | 2180 +++++++++-------- 1 file changed, 1106 insertions(+), 1074 deletions(-) diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index eaf17fb53f4..2d7397692f8 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -1,17 +1,15 @@ """The tests for the Owntracks device tracker.""" -import asyncio import json -import unittest -from unittest.mock import patch +from asynctest import patch +import pytest from tests.common import ( - assert_setup_component, fire_mqtt_message, mock_coro, mock_component, - get_test_home_assistant, mock_mqtt_component) + assert_setup_component, async_fire_mqtt_message, mock_coro, mock_component, + async_mock_mqtt_component) import homeassistant.components.device_tracker.owntracks as owntracks -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component from homeassistant.components import device_tracker from homeassistant.const import CONF_PLATFORM, STATE_NOT_HOME -from homeassistant.util.async_ import run_coroutine_threadsafe USER = 'greg' DEVICE = 'phone' @@ -275,982 +273,1016 @@ BAD_JSON_PREFIX = '--$this is bad json#--' BAD_JSON_SUFFIX = '** and it ends here ^^' -# def raise_on_not_implemented(hass, context, message): -def raise_on_not_implemented(): - """Throw NotImplemented.""" - raise NotImplementedError("oopsie") +@pytest.fixture +def setup_comp(hass): + """Initialize components.""" + mock_component(hass, 'group') + mock_component(hass, 'zone') + hass.loop.run_until_complete(async_mock_mqtt_component(hass)) + + hass.states.async_set( + 'zone.inner', 'zoning', INNER_ZONE) + + hass.states.async_set( + 'zone.inner_2', 'zoning', INNER_ZONE) + + hass.states.async_set( + 'zone.outer', 'zoning', OUTER_ZONE) -class BaseMQTT(unittest.TestCase): - """Base MQTT assert functions.""" +@pytest.fixture +def context(hass, setup_comp): + """Set up the mocked context.""" + patcher = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patcher.start() - hass = None + orig_context = owntracks.OwnTracksContext - def send_message(self, topic, message, corrupt=False): - """Test the sending of a message.""" - str_message = json.dumps(message) - if corrupt: - mod_message = BAD_JSON_PREFIX + str_message + BAD_JSON_SUFFIX - else: - mod_message = str_message - fire_mqtt_message(self.hass, topic, mod_message) - self.hass.block_till_done() + context = None - def assert_location_state(self, location): - """Test the assertion of a location state.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.state == location + def store_context(*args): + nonlocal context + context = orig_context(*args) + return context - def assert_location_latitude(self, latitude): - """Test the assertion of a location latitude.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('latitude') == latitude - - def assert_location_longitude(self, longitude): - """Test the assertion of a location longitude.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('longitude') == longitude - - def assert_location_accuracy(self, accuracy): - """Test the assertion of a location accuracy.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('gps_accuracy') == accuracy - - def assert_location_source_type(self, source_type): - """Test the assertion of source_type.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('source_type') == source_type - - -class TestDeviceTrackerOwnTracks(BaseMQTT): - """Test the OwnTrack sensor.""" - - # pylint: disable=invalid-name - def setup_method(self, _): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_mqtt_component(self.hass) - mock_component(self.hass, 'group') - mock_component(self.hass, 'zone') - - patcher = patch('homeassistant.components.device_tracker.' - 'DeviceTracker.async_update_config') - patcher.start() - self.addCleanup(patcher.stop) - - orig_context = owntracks.OwnTracksContext - - def store_context(*args): - self.context = orig_context(*args) - return self.context - - with patch('homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])), \ - patch('homeassistant.components.device_tracker.' - 'load_yaml_config_file', return_value=mock_coro({})), \ - patch.object(owntracks, 'OwnTracksContext', store_context), \ - assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { + with patch('homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])), \ + patch('homeassistant.components.device_tracker.' + 'load_yaml_config_file', return_value=mock_coro({})), \ + patch.object(owntracks, 'OwnTracksContext', store_context), \ + assert_setup_component(1, device_tracker.DOMAIN): + assert hass.loop.run_until_complete(async_setup_component( + hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'owntracks', CONF_MAX_GPS_ACCURACY: 200, CONF_WAYPOINT_IMPORT: True, CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] - }}) - - self.hass.states.set( - 'zone.inner', 'zoning', INNER_ZONE) - - self.hass.states.set( - 'zone.inner_2', 'zoning', INNER_ZONE) - - self.hass.states.set( - 'zone.outer', 'zoning', OUTER_ZONE) - - # Clear state between tests - # NB: state "None" is not a state that is created by Device - # so when we compare state to None in the tests this - # is really checking that it is still in its original - # test case state. See Device.async_update. - self.hass.states.set(DEVICE_TRACKER_STATE, None) - - def teardown_method(self, _): - """Stop everything that was started.""" - self.hass.stop() - - def assert_mobile_tracker_state(self, location, beacon=IBEACON_DEVICE): - """Test the assertion of a mobile beacon tracker state.""" - dev_id = MOBILE_BEACON_FMT.format(beacon) - state = self.hass.states.get(dev_id) - assert state.state == location - - def assert_mobile_tracker_latitude(self, latitude, beacon=IBEACON_DEVICE): - """Test the assertion of a mobile beacon tracker latitude.""" - dev_id = MOBILE_BEACON_FMT.format(beacon) - state = self.hass.states.get(dev_id) - assert state.attributes.get('latitude') == latitude - - def assert_mobile_tracker_accuracy(self, accuracy, beacon=IBEACON_DEVICE): - """Test the assertion of a mobile beacon tracker accuracy.""" - dev_id = MOBILE_BEACON_FMT.format(beacon) - state = self.hass.states.get(dev_id) - assert state.attributes.get('gps_accuracy') == accuracy - - def test_location_invalid_devid(self): # pylint: disable=invalid-name - """Test the update of a location.""" - self.send_message('owntracks/paulus/nexus-5x', LOCATION_MESSAGE) - state = self.hass.states.get('device_tracker.paulus_nexus5x') - assert state.state == 'outer' - - def test_location_update(self): - """Test the update of a location.""" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_accuracy(LOCATION_MESSAGE['acc']) - self.assert_location_state('outer') - - def test_location_inaccurate_gps(self): - """Test the location for inaccurate GPS information.""" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_INACCURATE) - - # Ignored inaccurate GPS. Location remains at previous. - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_longitude(LOCATION_MESSAGE['lon']) - - def test_location_zero_accuracy_gps(self): - """Ignore the location for zero accuracy GPS information.""" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY) - - # Ignored inaccurate GPS. Location remains at previous. - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_longitude(LOCATION_MESSAGE['lon']) - - # ------------------------------------------------------------------------ - # GPS based event entry / exit testing - - def test_event_gps_entry_exit(self): - """Test the entry event.""" - # Entering the owntracks circular region named "inner" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Updates ignored when in a zone - # note that LOCATION_MESSAGE is actually pretty far - # from INNER_ZONE and has good accuracy. I haven't - # received a transition message though so I'm still - # associated with the inner zone regardless of GPS. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - - # Exit switches back to GPS - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) - self.assert_location_state('outer') - - # Left clean zone state - assert not self.context.regions_entered[USER] - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Now sending a location update moves me again. - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_accuracy(LOCATION_MESSAGE['acc']) - - def test_event_gps_with_spaces(self): - """Test the entry event.""" - message = build_message({'desc': "inner 2"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner 2') - - message = build_message({'desc': "inner 2"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # Left clean zone state - assert not self.context.regions_entered[USER] - - def test_event_gps_entry_inaccurate(self): - """Test the event for inaccurate entry.""" - # Set location to the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_INACCURATE) - - # I enter the zone even though the message GPS was inaccurate. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - def test_event_gps_entry_exit_inaccurate(self): - """Test the event for inaccurate exit.""" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_INACCURATE) - - # Exit doesn't use inaccurate gps - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - # But does exit region correctly - assert not self.context.regions_entered[USER] - - def test_event_gps_entry_exit_zero_accuracy(self): - """Test entry/exit events with accuracy zero.""" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_ZERO) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_ZERO) - - # Exit doesn't use zero gps - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - # But does exit region correctly - assert not self.context.regions_entered[USER] - - def test_event_gps_exit_outside_zone_sets_away(self): - """Test the event for exit zone.""" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Exit message far away GPS location - message = build_message( - {'lon': 90.0, - 'lat': 90.0}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # Exit forces zone change to away - self.assert_location_state(STATE_NOT_HOME) - - def test_event_gps_entry_exit_right_order(self): - """Test the event for ordering.""" - # Enter inner zone - # Set location to the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Enter inner2 zone - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'inner' - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - # Exit inner - should be in 'outer' - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) - self.assert_location_state('outer') - - def test_event_gps_entry_exit_wrong_order(self): - """Test the event for wrong order.""" - # Enter inner zone - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Enter inner2 zone - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner - should still be in 'inner_2' - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'outer' - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) - self.assert_location_state('outer') - - def test_event_gps_entry_unknown_zone(self): - """Test the event for unknown zone.""" - # Just treat as location update - message = build_message( - {'desc': "unknown"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(REGION_GPS_ENTER_MESSAGE['lat']) - self.assert_location_state('inner') - - def test_event_gps_exit_unknown_zone(self): - """Test the event for unknown zone.""" - # Just treat as location update - message = build_message( - {'desc': "unknown"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_state('outer') - - def test_event_entry_zone_loading_dash(self): - """Test the event for zone landing.""" - # Make sure the leading - is ignored - # Owntracks uses this to switch on hold - message = build_message( - {'desc': "-inner"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - def test_events_only_on(self): - """Test events_only config suppresses location updates.""" - # Sending a location message that is not home - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.assert_location_state(STATE_NOT_HOME) - - self.context.events_only = True - - # Enter and Leave messages - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) - self.assert_location_state('outer') - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) - self.assert_location_state(STATE_NOT_HOME) - - # Sending a location message that is inside outer zone - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Ignored location update. Location remains at previous. - self.assert_location_state(STATE_NOT_HOME) - - def test_events_only_off(self): - """Test when events_only is False.""" - # Sending a location message that is not home - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.assert_location_state(STATE_NOT_HOME) - - self.context.events_only = False - - # Enter and Leave messages - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) - self.assert_location_state('outer') - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) - self.assert_location_state(STATE_NOT_HOME) - - # Sending a location message that is inside outer zone - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Location update processed - self.assert_location_state('outer') - - def test_event_source_type_entry_exit(self): - """Test the entry and exit events of source type.""" - # Entering the owntracks circular region named "inner" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - # source_type should be gps when entering using gps. - self.assert_location_source_type('gps') - - # owntracks shouldn't send beacon events with acc = 0 - self.send_message(EVENT_TOPIC, build_message( - {'acc': 1}, REGION_BEACON_ENTER_MESSAGE)) - - # We should be able to enter a beacon zone even inside a gps zone - self.assert_location_source_type('bluetooth_le') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - - # source_type should be gps when leaving using gps. - self.assert_location_source_type('gps') - - # owntracks shouldn't send beacon events with acc = 0 - self.send_message(EVENT_TOPIC, build_message( - {'acc': 1}, REGION_BEACON_LEAVE_MESSAGE)) - - self.assert_location_source_type('bluetooth_le') - - # Region Beacon based event entry / exit testing - - def test_event_region_entry_exit(self): - """Test the entry event.""" - # Seeing a beacon named "inner" - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Updates ignored when in a zone - # note that LOCATION_MESSAGE is actually pretty far - # from INNER_ZONE and has good accuracy. I haven't - # received a transition message though so I'm still - # associated with the inner zone regardless of GPS. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - - # Exit switches back to GPS but the beacon has no coords - # so I am still located at the center of the inner region - # until I receive a location update. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - # Left clean zone state - assert not self.context.regions_entered[USER] - - # Now sending a location update moves me again. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_accuracy(LOCATION_MESSAGE['acc']) - - def test_event_region_with_spaces(self): - """Test the entry event.""" - message = build_message({'desc': "inner 2"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner 2') - - message = build_message({'desc': "inner 2"}, - REGION_BEACON_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # Left clean zone state - assert not self.context.regions_entered[USER] - - def test_event_region_entry_exit_right_order(self): - """Test the event for ordering.""" - # Enter inner zone - # Set location to the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # See 'inner' region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.assert_location_state('inner') - - # See 'inner_2' region beacon - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'inner' - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - # Exit inner - should be in 'outer' - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - - # I have not had an actual location update yet and my - # coordinates are set to the center of the last region I - # entered which puts me in the inner zone. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - def test_event_region_entry_exit_wrong_order(self): - """Test the event for wrong order.""" - # Enter inner zone - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Enter inner2 zone - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner - should still be in 'inner_2' - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'outer' - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # I have not had an actual location update yet and my - # coordinates are set to the center of the last region I - # entered which puts me in the inner_2 zone. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner_2') - - def test_event_beacon_unknown_zone_no_location(self): - """Test the event for unknown zone.""" - # A beacon which does not match a HA zone is the - # definition of a mobile beacon. In this case, "unknown" - # will be turned into device_tracker.beacon_unknown and - # that will be tracked at my current location. Except - # in this case my Device hasn't had a location message - # yet so it's in an odd state where it has state.state - # None and no GPS coords so set the beacon to. - - message = build_message( - {'desc': "unknown"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # My current state is None because I haven't seen a - # location message or a GPS or Region # Beacon event - # message. None is the state the test harness set for - # the Device during test case setup. - self.assert_location_state('None') - - # home is the state of a Device constructed through - # the normal code path on it's first observation with - # the conditions I pass along. - self.assert_mobile_tracker_state('home', 'unknown') - - def test_event_beacon_unknown_zone(self): - """Test the event for unknown zone.""" - # A beacon which does not match a HA zone is the - # definition of a mobile beacon. In this case, "unknown" - # will be turned into device_tracker.beacon_unknown and - # that will be tracked at my current location. First I - # set my location so that my state is 'outer' - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_state('outer') - - message = build_message( - {'desc': "unknown"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # My state is still outer and now the unknown beacon - # has joined me at outer. - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer', 'unknown') - - def test_event_beacon_entry_zone_loading_dash(self): - """Test the event for beacon zone landing.""" - # Make sure the leading - is ignored - # Owntracks uses this to switch on hold - - message = build_message( - {'desc': "-inner"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - # ------------------------------------------------------------------------ - # Mobile Beacon based event entry / exit testing - - def test_mobile_enter_move_beacon(self): - """Test the movement of a beacon.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # I see the 'keys' beacon. I set the location of the - # beacon_keys tracker to my current device location. - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - - self.assert_mobile_tracker_latitude(LOCATION_MESSAGE['lat']) - self.assert_mobile_tracker_state('outer') - - # Location update to outside of defined zones. - # I am now 'not home' and neither are my keys. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - - self.assert_location_state(STATE_NOT_HOME) - self.assert_mobile_tracker_state(STATE_NOT_HOME) - - not_home_lat = LOCATION_MESSAGE_NOT_HOME['lat'] - self.assert_location_latitude(not_home_lat) - self.assert_mobile_tracker_latitude(not_home_lat) - - def test_mobile_enter_exit_region_beacon(self): - """Test the enter and the exit of a mobile beacon.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # I see a new mobile beacon - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - # GPS enter message should move beacon - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - self.assert_mobile_tracker_state(REGION_GPS_ENTER_MESSAGE['desc']) - - # Exit inner zone to outer zone should move beacon to - # center of outer zone - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.assert_mobile_tracker_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_mobile_tracker_state('outer') - - def test_mobile_exit_move_beacon(self): - """Test the exit move of a beacon.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # I see a new mobile beacon - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - # Exit mobile beacon, should set location - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - # Move after exit should do nothing - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - def test_mobile_multiple_async_enter_exit(self): - """Test the multiple entering.""" - # Test race condition - for _ in range(0, 20): - fire_mqtt_message( - self.hass, EVENT_TOPIC, - json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) - fire_mqtt_message( - self.hass, EVENT_TOPIC, - json.dumps(MOBILE_BEACON_LEAVE_EVENT_MESSAGE)) - - fire_mqtt_message( - self.hass, EVENT_TOPIC, + }})) + + def get_context(): + """Get the current context.""" + return context + + yield get_context + + patcher.stop() + + +async def send_message(hass, topic, message, corrupt=False): + """Test the sending of a message.""" + str_message = json.dumps(message) + if corrupt: + mod_message = BAD_JSON_PREFIX + str_message + BAD_JSON_SUFFIX + else: + mod_message = str_message + async_fire_mqtt_message(hass, topic, mod_message) + await hass.async_block_till_done() + await hass.async_block_till_done() + + +def assert_location_state(hass, location): + """Test the assertion of a location state.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.state == location + + +def assert_location_latitude(hass, latitude): + """Test the assertion of a location latitude.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('latitude') == latitude + + +def assert_location_longitude(hass, longitude): + """Test the assertion of a location longitude.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('longitude') == longitude + + +def assert_location_accuracy(hass, accuracy): + """Test the assertion of a location accuracy.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('gps_accuracy') == accuracy + + +def assert_location_source_type(hass, source_type): + """Test the assertion of source_type.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('source_type') == source_type + + +def assert_mobile_tracker_state(hass, location, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker state.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = hass.states.get(dev_id) + assert state.state == location + + +def assert_mobile_tracker_latitude(hass, latitude, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker latitude.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = hass.states.get(dev_id) + assert state.attributes.get('latitude') == latitude + + +def assert_mobile_tracker_accuracy(hass, accuracy, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker accuracy.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = hass.states.get(dev_id) + assert state.attributes.get('gps_accuracy') == accuracy + + +async def test_location_invalid_devid(hass, context): + """Test the update of a location.""" + await send_message(hass, 'owntracks/paulus/nexus-5x', LOCATION_MESSAGE) + state = hass.states.get('device_tracker.paulus_nexus5x') + assert state.state == 'outer' + + +async def test_location_update(hass, context): + """Test the update of a location.""" + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_location_inaccurate_gps(hass, context): + """Test the location for inaccurate GPS information.""" + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_INACCURATE) + + # Ignored inaccurate GPS. Location remains at previous. + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_longitude(hass, LOCATION_MESSAGE['lon']) + + +async def test_location_zero_accuracy_gps(hass, context): + """Ignore the location for zero accuracy GPS information.""" + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY) + + # Ignored inaccurate GPS. Location remains at previous. + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_longitude(hass, LOCATION_MESSAGE['lon']) + + +# ------------------------------------------------------------------------ +# GPS based event entry / exit testing +async def test_event_gps_entry_exit(hass, context): + """Test the entry event.""" + # Entering the owntracks circular region named "inner" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Updates ignored when in a zone + # note that LOCATION_MESSAGE is actually pretty far + # from INNER_ZONE and has good accuracy. I haven't + # received a transition message though so I'm still + # associated with the inner zone regardless of GPS. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + + # Exit switches back to GPS + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + # Left clean zone state + assert not context().regions_entered[USER] + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Now sending a location update moves me again. + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + + +async def test_event_gps_with_spaces(hass, context): + """Test the entry event.""" + message = build_message({'desc': "inner 2"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner 2') + + message = build_message({'desc': "inner 2"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # Left clean zone state + assert not context().regions_entered[USER] + + +async def test_event_gps_entry_inaccurate(hass, context): + """Test the event for inaccurate entry.""" + # Set location to the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_INACCURATE) + + # I enter the zone even though the message GPS was inaccurate. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + +async def test_event_gps_entry_exit_inaccurate(hass, context): + """Test the event for inaccurate exit.""" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_INACCURATE) + + # Exit doesn't use inaccurate gps + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + # But does exit region correctly + assert not context().regions_entered[USER] + + +async def test_event_gps_entry_exit_zero_accuracy(hass, context): + """Test entry/exit events with accuracy zero.""" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_ZERO) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_ZERO) + + # Exit doesn't use zero gps + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + # But does exit region correctly + assert not context().regions_entered[USER] + + +async def test_event_gps_exit_outside_zone_sets_away(hass, context): + """Test the event for exit zone.""" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Exit message far away GPS location + message = build_message( + {'lon': 90.0, + 'lat': 90.0}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # Exit forces zone change to away + assert_location_state(hass, STATE_NOT_HOME) + + +async def test_event_gps_entry_exit_right_order(hass, context): + """Test the event for ordering.""" + # Enter inner zone + # Set location to the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'inner' + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + # Exit inner - should be in 'outer' + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_event_gps_entry_exit_wrong_order(hass, context): + """Test the event for wrong order.""" + # Enter inner zone + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner - should still be in 'inner_2' + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'outer' + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_event_gps_entry_unknown_zone(hass, context): + """Test the event for unknown zone.""" + # Just treat as location update + message = build_message( + {'desc': "unknown"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_latitude(hass, REGION_GPS_ENTER_MESSAGE['lat']) + assert_location_state(hass, 'inner') + + +async def test_event_gps_exit_unknown_zone(hass, context): + """Test the event for unknown zone.""" + # Just treat as location update + message = build_message( + {'desc': "unknown"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_state(hass, 'outer') + + +async def test_event_entry_zone_loading_dash(hass, context): + """Test the event for zone landing.""" + # Make sure the leading - is ignored + # Owntracks uses this to switch on hold + message = build_message( + {'desc': "-inner"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + +async def test_events_only_on(hass, context): + """Test events_only config suppresses location updates.""" + # Sending a location message that is not home + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + assert_location_state(hass, STATE_NOT_HOME) + + context().events_only = True + + # Enter and Leave messages + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) + assert_location_state(hass, 'outer') + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + assert_location_state(hass, STATE_NOT_HOME) + + # Sending a location message that is inside outer zone + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Ignored location update. Location remains at previous. + assert_location_state(hass, STATE_NOT_HOME) + + +async def test_events_only_off(hass, context): + """Test when events_only is False.""" + # Sending a location message that is not home + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + assert_location_state(hass, STATE_NOT_HOME) + + context().events_only = False + + # Enter and Leave messages + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) + assert_location_state(hass, 'outer') + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + assert_location_state(hass, STATE_NOT_HOME) + + # Sending a location message that is inside outer zone + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Location update processed + assert_location_state(hass, 'outer') + + +async def test_event_source_type_entry_exit(hass, context): + """Test the entry and exit events of source type.""" + # Entering the owntracks circular region named "inner" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # source_type should be gps when entering using gps. + assert_location_source_type(hass, 'gps') + + # owntracks shouldn't send beacon events with acc = 0 + await send_message(hass, EVENT_TOPIC, build_message( + {'acc': 1}, REGION_BEACON_ENTER_MESSAGE)) + + # We should be able to enter a beacon zone even inside a gps zone + assert_location_source_type(hass, 'bluetooth_le') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + + # source_type should be gps when leaving using gps. + assert_location_source_type(hass, 'gps') + + # owntracks shouldn't send beacon events with acc = 0 + await send_message(hass, EVENT_TOPIC, build_message( + {'acc': 1}, REGION_BEACON_LEAVE_MESSAGE)) + + assert_location_source_type(hass, 'bluetooth_le') + + +# Region Beacon based event entry / exit testing +async def test_event_region_entry_exit(hass, context): + """Test the entry event.""" + # Seeing a beacon named "inner" + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Updates ignored when in a zone + # note that LOCATION_MESSAGE is actually pretty far + # from INNER_ZONE and has good accuracy. I haven't + # received a transition message though so I'm still + # associated with the inner zone regardless of GPS. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + + # Exit switches back to GPS but the beacon has no coords + # so I am still located at the center of the inner region + # until I receive a location update. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + # Left clean zone state + assert not context().regions_entered[USER] + + # Now sending a location update moves me again. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + + +async def test_event_region_with_spaces(hass, context): + """Test the entry event.""" + message = build_message({'desc': "inner 2"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner 2') + + message = build_message({'desc': "inner 2"}, + REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # Left clean zone state + assert not context().regions_entered[USER] + + +async def test_event_region_entry_exit_right_order(hass, context): + """Test the event for ordering.""" + # Enter inner zone + # Set location to the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # See 'inner' region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # See 'inner_2' region beacon + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'inner' + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + # Exit inner - should be in 'outer' + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + + # I have not had an actual location update yet and my + # coordinates are set to the center of the last region I + # entered which puts me in the inner zone. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + +async def test_event_region_entry_exit_wrong_order(hass, context): + """Test the event for wrong order.""" + # Enter inner zone + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner - should still be in 'inner_2' + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'outer' + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # I have not had an actual location update yet and my + # coordinates are set to the center of the last region I + # entered which puts me in the inner_2 zone. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner_2') + + +async def test_event_beacon_unknown_zone_no_location(hass, context): + """Test the event for unknown zone.""" + # A beacon which does not match a HA zone is the + # definition of a mobile beacon. In this case, "unknown" + # will be turned into device_tracker.beacon_unknown and + # that will be tracked at my current location. Except + # in this case my Device hasn't had a location message + # yet so it's in an odd state where it has state.state + # None and no GPS coords so set the beacon to. + hass.states.async_set(DEVICE_TRACKER_STATE, None) + + message = build_message( + {'desc': "unknown"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # My current state is None because I haven't seen a + # location message or a GPS or Region # Beacon event + # message. None is the state the test harness set for + # the Device during test case setup. + assert_location_state(hass, 'None') + + # home is the state of a Device constructed through + # the normal code path on it's first observation with + # the conditions I pass along. + assert_mobile_tracker_state(hass, 'home', 'unknown') + + +async def test_event_beacon_unknown_zone(hass, context): + """Test the event for unknown zone.""" + # A beacon which does not match a HA zone is the + # definition of a mobile beacon. In this case, "unknown" + # will be turned into device_tracker.beacon_unknown and + # that will be tracked at my current location. First I + # set my location so that my state is 'outer' + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_state(hass, 'outer') + + message = build_message( + {'desc': "unknown"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # My state is still outer and now the unknown beacon + # has joined me at outer. + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer', 'unknown') + + +async def test_event_beacon_entry_zone_loading_dash(hass, context): + """Test the event for beacon zone landing.""" + # Make sure the leading - is ignored + # Owntracks uses this to switch on hold + + message = build_message( + {'desc': "-inner"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + +# ------------------------------------------------------------------------ +# Mobile Beacon based event entry / exit testing +async def test_mobile_enter_move_beacon(hass, context): + """Test the movement of a beacon.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see the 'keys' beacon. I set the location of the + # beacon_keys tracker to my current device location. + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + + assert_mobile_tracker_latitude(hass, LOCATION_MESSAGE['lat']) + assert_mobile_tracker_state(hass, 'outer') + + # Location update to outside of defined zones. + # I am now 'not home' and neither are my keys. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + + assert_location_state(hass, STATE_NOT_HOME) + assert_mobile_tracker_state(hass, STATE_NOT_HOME) + + not_home_lat = LOCATION_MESSAGE_NOT_HOME['lat'] + assert_location_latitude(hass, not_home_lat) + assert_mobile_tracker_latitude(hass, not_home_lat) + + +async def test_mobile_enter_exit_region_beacon(hass, context): + """Test the enter and the exit of a mobile beacon.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see a new mobile beacon + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + # GPS enter message should move beacon + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + assert_mobile_tracker_state(hass, REGION_GPS_ENTER_MESSAGE['desc']) + + # Exit inner zone to outer zone should move beacon to + # center of outer zone + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + assert_mobile_tracker_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_mobile_tracker_state(hass, 'outer') + + +async def test_mobile_exit_move_beacon(hass, context): + """Test the exit move of a beacon.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see a new mobile beacon + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + # Exit mobile beacon, should set location + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + # Move after exit should do nothing + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + +async def test_mobile_multiple_async_enter_exit(hass, context): + """Test the multiple entering.""" + # Test race condition + for _ in range(0, 20): + async_fire_mqtt_message( + hass, EVENT_TOPIC, json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) + async_fire_mqtt_message( + hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_LEAVE_EVENT_MESSAGE)) - self.hass.block_till_done() - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - assert len(self.context.mobile_beacons_active['greg_phone']) == \ - 0 + async_fire_mqtt_message( + hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) - def test_mobile_multiple_enter_exit(self): - """Test the multiple entering.""" - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + await hass.async_block_till_done() + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert len(context().mobile_beacons_active['greg_phone']) == \ + 0 - assert len(self.context.mobile_beacons_active['greg_phone']) == \ - 0 - def test_complex_movement(self): - """Test a complex sequence representative of real-world use.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_state('outer') +async def test_mobile_multiple_enter_exit(hass, context): + """Test the multiple entering.""" + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - # gps to inner location and event, as actually happens with OwnTracks - location_message = build_message( - {'lat': REGION_GPS_ENTER_MESSAGE['lat'], - 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') + assert len(context().mobile_beacons_active['greg_phone']) == \ + 0 - # region beacon enter inner event and location as actually happens - # with OwnTracks - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - # see keys mobile beacon and location message as actually happens - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') +async def test_complex_movement(hass, context): + """Test a complex sequence representative of real-world use.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_state(hass, 'outer') - # Slightly odd, I leave the location by gps before I lose - # sight of the region beacon. This is also a little odd in - # that my GPS coords are now in the 'outer' zone but I did not - # "enter" that zone when I started up so my location is not - # the center of OUTER_ZONE, but rather just my GPS location. + # gps to inner location and event, as actually happens with OwnTracks + location_message = build_message( + {'lat': REGION_GPS_ENTER_MESSAGE['lat'], + 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') - # gps out of inner event and location - location_message = build_message( - {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], - 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_mobile_tracker_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer') + # region beacon enter inner event and location as actually happens + # with OwnTracks + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') - # region beacon leave inner - location_message = build_message( - {'lat': location_message['lat'] - FIVE_M, - 'lon': location_message['lon'] - FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(location_message['lat']) - self.assert_mobile_tracker_latitude(location_message['lat']) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer') + # see keys mobile beacon and location message as actually happens + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') - # lose keys mobile beacon - lost_keys_location_message = build_message( - {'lat': location_message['lat'] - FIVE_M, - 'lon': location_message['lon'] - FIVE_M}, - LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, lost_keys_location_message) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_location_latitude(lost_keys_location_message['lat']) - self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer') + # Slightly odd, I leave the location by gps before I lose + # sight of the region beacon. This is also a little odd in + # that my GPS coords are now in the 'outer' zone but I did not + # "enter" that zone when I started up so my location is not + # the center of OUTER_ZONE, but rather just my GPS location. - # gps leave outer - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) - self.assert_location_latitude(LOCATION_MESSAGE_NOT_HOME['lat']) - self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) - self.assert_location_state('not_home') - self.assert_mobile_tracker_state('outer') + # gps out of inner event and location + location_message = build_message( + {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], + 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_mobile_tracker_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer') - # location move not home - location_message = build_message( - {'lat': LOCATION_MESSAGE_NOT_HOME['lat'] - FIVE_M, - 'lon': LOCATION_MESSAGE_NOT_HOME['lon'] - FIVE_M}, - LOCATION_MESSAGE_NOT_HOME) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(location_message['lat']) - self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) - self.assert_location_state('not_home') - self.assert_mobile_tracker_state('outer') + # region beacon leave inner + location_message = build_message( + {'lat': location_message['lat'] - FIVE_M, + 'lon': location_message['lon'] - FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, location_message['lat']) + assert_mobile_tracker_latitude(hass, location_message['lat']) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer') - def test_complex_movement_sticky_keys_beacon(self): - """Test a complex sequence which was previously broken.""" - # I am not_home - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_state('outer') + # lose keys mobile beacon + lost_keys_location_message = build_message( + {'lat': location_message['lat'] - FIVE_M, + 'lon': location_message['lon'] - FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, lost_keys_location_message) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert_location_latitude(hass, lost_keys_location_message['lat']) + assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat']) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer') - # gps to inner location and event, as actually happens with OwnTracks - location_message = build_message( - {'lat': REGION_GPS_ENTER_MESSAGE['lat'], - 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') + # gps leave outer + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + assert_location_latitude(hass, LOCATION_MESSAGE_NOT_HOME['lat']) + assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat']) + assert_location_state(hass, 'not_home') + assert_mobile_tracker_state(hass, 'outer') - # see keys mobile beacon and location message as actually happens - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') + # location move not home + location_message = build_message( + {'lat': LOCATION_MESSAGE_NOT_HOME['lat'] - FIVE_M, + 'lon': LOCATION_MESSAGE_NOT_HOME['lon'] - FIVE_M}, + LOCATION_MESSAGE_NOT_HOME) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, location_message['lat']) + assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat']) + assert_location_state(hass, 'not_home') + assert_mobile_tracker_state(hass, 'outer') - # region beacon enter inner event and location as actually happens - # with OwnTracks - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - # This sequence of moves would cause keys to follow - # greg_phone around even after the OwnTracks sent - # a mobile beacon 'leave' event for the keys. - # leave keys - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') +async def test_complex_movement_sticky_keys_beacon(hass, context): + """Test a complex sequence which was previously broken.""" + # I am not_home + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_state(hass, 'outer') - # leave inner region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') + # gps to inner location and event, as actually happens with OwnTracks + location_message = build_message( + {'lat': REGION_GPS_ENTER_MESSAGE['lat'], + 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') - # enter inner region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') + # see keys mobile beacon and location message as actually happens + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') - # enter keys - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') + # region beacon enter inner event and location as actually happens + # with OwnTracks + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') - # leave keys - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') + # This sequence of moves would cause keys to follow + # greg_phone around even after the OwnTracks sent + # a mobile beacon 'leave' event for the keys. + # leave keys + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') - # leave inner region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') + # leave inner region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') - # GPS leave inner region, I'm in the 'outer' region now - # but on GPS coords - leave_location_message = build_message( - {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], - 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, leave_location_message) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('inner') - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) + # enter inner region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') - def test_waypoint_import_simple(self): - """Test a simple import of list of waypoints.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - assert wayp is not None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[1]) - assert wayp is not None + # enter keys + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') - def test_waypoint_import_blacklist(self): - """Test import of list of waypoints for blacklisted user.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC_BLOCKED, waypoints_message) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) - assert wayp is None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) - assert wayp is None + # leave keys + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') - def test_waypoint_import_no_whitelist(self): - """Test import of list of waypoints with no whitelist set.""" - @asyncio.coroutine - def mock_see(**kwargs): - """Fake see method for owntracks.""" - return + # leave inner region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') - test_config = { - CONF_PLATFORM: 'owntracks', - CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True, - CONF_MQTT_TOPIC: 'owntracks/#', - } - run_coroutine_threadsafe(owntracks.async_setup_scanner( - self.hass, test_config, mock_see), self.hass.loop).result() - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC_BLOCKED, waypoints_message) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) - assert wayp is not None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) - assert wayp is not None + # GPS leave inner region, I'm in the 'outer' region now + # but on GPS coords + leave_location_message = build_message( + {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], + 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, leave_location_message) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'inner') + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) - def test_waypoint_import_bad_json(self): - """Test importing a bad JSON payload.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message, True) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) - assert wayp is None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) - assert wayp is None - def test_waypoint_import_existing(self): - """Test importing a zone that exists.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message) - # Get the first waypoint exported - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - # Send an update - waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message) - new_wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - assert wayp == new_wayp +async def test_waypoint_import_simple(hass, context): + """Test a simple import of list of waypoints.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + assert wayp is not None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[1]) + assert wayp is not None - def test_single_waypoint_import(self): - """Test single waypoint message.""" - waypoint_message = WAYPOINT_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC, waypoint_message) - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - assert wayp is not None - def test_not_implemented_message(self): - """Handle not implemented message type.""" - patch_handler = patch('homeassistant.components.device_tracker.' - 'owntracks.async_handle_not_impl_msg', - return_value=mock_coro(False)) - patch_handler.start() - assert not self.send_message(LWT_TOPIC, LWT_MESSAGE) - patch_handler.stop() +async def test_waypoint_import_blacklist(hass, context): + """Test import of list of waypoints for blacklisted user.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + assert wayp is None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + assert wayp is None - def test_unsupported_message(self): - """Handle not implemented message type.""" - patch_handler = patch('homeassistant.components.device_tracker.' - 'owntracks.async_handle_unsupported_msg', - return_value=mock_coro(False)) - patch_handler.start() - assert not self.send_message(BAD_TOPIC, BAD_MESSAGE) - patch_handler.stop() + +async def test_waypoint_import_no_whitelist(hass, context): + """Test import of list of waypoints with no whitelist set.""" + async def mock_see(**kwargs): + """Fake see method for owntracks.""" + return + + test_config = { + CONF_PLATFORM: 'owntracks', + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True, + CONF_MQTT_TOPIC: 'owntracks/#', + } + await owntracks.async_setup_scanner(hass, test_config, mock_see) + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + assert wayp is not None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + assert wayp is not None + + +async def test_waypoint_import_bad_json(hass, context): + """Test importing a bad JSON payload.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message, True) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + assert wayp is None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + assert wayp is None + + +async def test_waypoint_import_existing(hass, context): + """Test importing a zone that exists.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message) + # Get the first waypoint exported + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + # Send an update + waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message) + new_wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + assert wayp == new_wayp + + +async def test_single_waypoint_import(hass, context): + """Test single waypoint message.""" + waypoint_message = WAYPOINT_MESSAGE.copy() + await send_message(hass, WAYPOINT_TOPIC, waypoint_message) + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + assert wayp is not None + + +async def test_not_implemented_message(hass, context): + """Handle not implemented message type.""" + patch_handler = patch('homeassistant.components.device_tracker.' + 'owntracks.async_handle_not_impl_msg', + return_value=mock_coro(False)) + patch_handler.start() + assert not await send_message(hass, LWT_TOPIC, LWT_MESSAGE) + patch_handler.stop() + + +async def test_unsupported_message(hass, context): + """Handle not implemented message type.""" + patch_handler = patch('homeassistant.components.device_tracker.' + 'owntracks.async_handle_unsupported_msg', + return_value=mock_coro(False)) + patch_handler.start() + assert not await send_message(hass, BAD_TOPIC, BAD_MESSAGE) + patch_handler.stop() def generate_ciphers(secret): @@ -1310,162 +1342,162 @@ def mock_cipher(): return len(TEST_SECRET_KEY), mock_decrypt -class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): - """Test the OwnTrack sensor.""" +@pytest.fixture +def config_context(hass, setup_comp): + """Set up the mocked context.""" + patch_load = patch( + 'homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])) + patch_load.start() - # pylint: disable=invalid-name + patch_save = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patch_save.start() - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_mqtt_component(self.hass) - mock_component(self.hass, 'group') - mock_component(self.hass, 'zone') + yield - self.patch_load = patch( - 'homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])) - self.patch_load.start() + patch_load.stop() + patch_save.stop() - self.patch_save = patch('homeassistant.components.device_tracker.' - 'DeviceTracker.async_update_config') - self.patch_save.start() - def teardown_method(self, method): - """Tear down resources.""" - self.patch_load.stop() - self.patch_save.stop() - self.hass.stop() +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload(hass, config_context): + """Test encrypted payload.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: TEST_SECRET_KEY, + }}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload(self): - """Test encrypted payload.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_topic_key(self): - """Test encrypted payload with a topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: TEST_SECRET_KEY, - }}}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_topic_key(hass, config_context): + """Test encrypted payload with a topic key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: { + LOCATION_TOPIC: TEST_SECRET_KEY, + }}}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_no_key(self): - """Test encrypted payload with no key, .""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - # key missing - }}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_wrong_key(self): - """Test encrypted payload with wrong key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: 'wrong key', - }}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_no_key(hass, config_context): + """Test encrypted payload with no key, .""" + assert hass.states.get(DEVICE_TRACKER_STATE) is None + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + # key missing + }}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_wrong_topic_key(self): - """Test encrypted payload with wrong topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: 'wrong key' - }}}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_no_topic_key(self): - """Test encrypted payload with no topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' - }}}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_wrong_key(hass, config_context): + """Test encrypted payload with wrong key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: 'wrong key', + }}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_wrong_topic_key(hass, config_context): + """Test encrypted payload with wrong topic key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: { + LOCATION_TOPIC: 'wrong key' + }}}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_no_topic_key(hass, config_context): + """Test encrypted payload with no topic key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: { + 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' + }}}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + + +async def test_encrypted_payload_libsodium(hass, config_context): + """Test sending encrypted message payload.""" try: - import libnacl + import libnacl # noqa: F401 except (ImportError, OSError): - libnacl = None + pytest.skip("libnacl/libsodium is not installed") + return - @unittest.skipUnless(libnacl, "libnacl/libsodium is not installed") - def test_encrypted_payload_libsodium(self): - """Test sending encrypted message payload.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: TEST_SECRET_KEY, + }}) - self.send_message(LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) + await send_message(hass, LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - def test_customized_mqtt_topic(self): - """Test subscribing to a custom mqtt topic.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_MQTT_TOPIC: 'mytracks/#', - }}) - topic = 'mytracks/{}/{}'.format(USER, DEVICE) +async def test_customized_mqtt_topic(hass, config_context): + """Test subscribing to a custom mqtt topic.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_MQTT_TOPIC: 'mytracks/#', + }}) - self.send_message(topic, LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) + topic = 'mytracks/{}/{}'.format(USER, DEVICE) - def test_region_mapping(self): - """Test region to zone mapping.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_REGION_MAPPING: { - 'foo': 'inner' - }, - }}) + await send_message(hass, topic, LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - self.hass.states.set( - 'zone.inner', 'zoning', INNER_ZONE) - message = build_message({'desc': 'foo'}, REGION_GPS_ENTER_MESSAGE) - assert message['desc'] == 'foo' +async def test_region_mapping(hass, config_context): + """Test region to zone mapping.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_REGION_MAPPING: { + 'foo': 'inner' + }, + }}) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') + hass.states.async_set( + 'zone.inner', 'zoning', INNER_ZONE) + + message = build_message({'desc': 'foo'}, REGION_GPS_ENTER_MESSAGE) + assert message['desc'] == 'foo' + + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') From f860cac4ea5ab1276dd1e228d92e80433651cc50 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Nov 2018 22:20:13 +0100 Subject: [PATCH 13/47] OwnTracks Config Entry (#18759) * OwnTracks Config Entry * Fix test * Fix headers * Lint * Username for android only * Update translations * Tweak translation * Create config entry if not there * Update reqs * Types * Lint --- .../components/device_tracker/__init__.py | 11 + .../components/device_tracker/owntracks.py | 158 +------------ .../device_tracker/owntracks_http.py | 82 ------- .../owntracks/.translations/en.json | 17 ++ .../components/owntracks/__init__.py | 219 ++++++++++++++++++ .../components/owntracks/config_flow.py | 79 +++++++ .../components/owntracks/strings.json | 17 ++ homeassistant/config_entries.py | 1 + homeassistant/setup.py | 34 ++- requirements_all.txt | 3 +- .../device_tracker/test_owntracks.py | 154 ++++++------ tests/components/owntracks/__init__.py | 1 + .../components/owntracks/test_config_flow.py | 1 + .../test_init.py} | 97 +++++--- tests/test_setup.py | 35 ++- 15 files changed, 554 insertions(+), 355 deletions(-) delete mode 100644 homeassistant/components/device_tracker/owntracks_http.py create mode 100644 homeassistant/components/owntracks/.translations/en.json create mode 100644 homeassistant/components/owntracks/__init__.py create mode 100644 homeassistant/components/owntracks/config_flow.py create mode 100644 homeassistant/components/owntracks/strings.json create mode 100644 tests/components/owntracks/__init__.py create mode 100644 tests/components/owntracks/test_config_flow.py rename tests/components/{device_tracker/test_owntracks_http.py => owntracks/test_init.py} (51%) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index a43a7c93bdc..ad792d035cc 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -182,6 +182,9 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): setup = await hass.async_add_job( platform.setup_scanner, hass, p_config, tracker.see, disc_info) + elif hasattr(platform, 'async_setup_entry'): + setup = await platform.async_setup_entry( + hass, p_config, tracker.async_see) else: raise HomeAssistantError("Invalid device_tracker platform.") @@ -197,6 +200,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): except Exception: # pylint: disable=broad-except _LOGGER.exception("Error setting up platform %s", p_type) + hass.data[DOMAIN] = async_setup_platform + setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config in config_per_platform(config, DOMAIN)] if setup_tasks: @@ -230,6 +235,12 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): return True +async def async_setup_entry(hass, entry): + """Set up an entry.""" + await hass.data[DOMAIN](entry.domain, entry) + return True + + class DeviceTracker: """Representation of a device tracker.""" diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 10f71450f69..ae2b9d6146b 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -7,55 +7,29 @@ https://home-assistant.io/components/device_tracker.owntracks/ import base64 import json import logging -from collections import defaultdict -import voluptuous as vol - -from homeassistant.components import mqtt -import homeassistant.helpers.config_validation as cv from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, ATTR_SOURCE_TYPE, SOURCE_TYPE_BLUETOOTH_LE, - SOURCE_TYPE_GPS + ATTR_SOURCE_TYPE, SOURCE_TYPE_BLUETOOTH_LE, SOURCE_TYPE_GPS ) +from homeassistant.components.owntracks import DOMAIN as OT_DOMAIN from homeassistant.const import STATE_HOME -from homeassistant.core import callback from homeassistant.util import slugify, decorator -REQUIREMENTS = ['libnacl==1.6.1'] + +DEPENDENCIES = ['owntracks'] _LOGGER = logging.getLogger(__name__) HANDLERS = decorator.Registry() -BEACON_DEV_ID = 'beacon' -CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' -CONF_SECRET = 'secret' -CONF_WAYPOINT_IMPORT = 'waypoints' -CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' -CONF_MQTT_TOPIC = 'mqtt_topic' -CONF_REGION_MAPPING = 'region_mapping' -CONF_EVENTS_ONLY = 'events_only' - -DEPENDENCIES = ['mqtt'] - -DEFAULT_OWNTRACKS_TOPIC = 'owntracks/#' -REGION_MAPPING = {} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), - vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, - vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean, - vol.Optional(CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC): - mqtt.valid_subscribe_topic, - vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All( - cv.ensure_list, [cv.string]), - vol.Optional(CONF_SECRET): vol.Any( - vol.Schema({vol.Optional(cv.string): cv.string}), - cv.string), - vol.Optional(CONF_REGION_MAPPING, default=REGION_MAPPING): dict -}) +async def async_setup_entry(hass, entry, async_see): + """Set up OwnTracks based off an entry.""" + hass.data[OT_DOMAIN]['context'].async_see = async_see + hass.helpers.dispatcher.async_dispatcher_connect( + OT_DOMAIN, async_handle_message) + return True def get_cipher(): @@ -72,29 +46,6 @@ def get_cipher(): return (KEYLEN, decrypt) -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up an OwnTracks tracker.""" - context = context_from_config(async_see, config) - - async def async_handle_mqtt_message(topic, payload, qos): - """Handle incoming OwnTracks message.""" - try: - message = json.loads(payload) - except ValueError: - # If invalid JSON - _LOGGER.error("Unable to parse payload as JSON: %s", payload) - return - - message['topic'] = topic - - await async_handle_message(hass, context, message) - - await mqtt.async_subscribe( - hass, context.mqtt_topic, async_handle_mqtt_message, 1) - - return True - - def _parse_topic(topic, subscribe_topic): """Parse an MQTT topic {sub_topic}/user/dev, return (user, dev) tuple. @@ -202,93 +153,6 @@ def _decrypt_payload(secret, topic, ciphertext): return None -def context_from_config(async_see, config): - """Create an async context from Home Assistant config.""" - max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) - waypoint_import = config.get(CONF_WAYPOINT_IMPORT) - waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) - secret = config.get(CONF_SECRET) - region_mapping = config.get(CONF_REGION_MAPPING) - events_only = config.get(CONF_EVENTS_ONLY) - mqtt_topic = config.get(CONF_MQTT_TOPIC) - - return OwnTracksContext(async_see, secret, max_gps_accuracy, - waypoint_import, waypoint_whitelist, - region_mapping, events_only, mqtt_topic) - - -class OwnTracksContext: - """Hold the current OwnTracks context.""" - - def __init__(self, async_see, secret, max_gps_accuracy, import_waypoints, - waypoint_whitelist, region_mapping, events_only, mqtt_topic): - """Initialize an OwnTracks context.""" - self.async_see = async_see - self.secret = secret - self.max_gps_accuracy = max_gps_accuracy - self.mobile_beacons_active = defaultdict(set) - self.regions_entered = defaultdict(list) - self.import_waypoints = import_waypoints - self.waypoint_whitelist = waypoint_whitelist - self.region_mapping = region_mapping - self.events_only = events_only - self.mqtt_topic = mqtt_topic - - @callback - def async_valid_accuracy(self, message): - """Check if we should ignore this message.""" - acc = message.get('acc') - - if acc is None: - return False - - try: - acc = float(acc) - except ValueError: - return False - - if acc == 0: - _LOGGER.warning( - "Ignoring %s update because GPS accuracy is zero: %s", - message['_type'], message) - return False - - if self.max_gps_accuracy is not None and \ - acc > self.max_gps_accuracy: - _LOGGER.info("Ignoring %s update because expected GPS " - "accuracy %s is not met: %s", - message['_type'], self.max_gps_accuracy, - message) - return False - - return True - - async def async_see_beacons(self, hass, dev_id, kwargs_param): - """Set active beacons to the current location.""" - kwargs = kwargs_param.copy() - - # Mobile beacons should always be set to the location of the - # tracking device. I get the device state and make the necessary - # changes to kwargs. - device_tracker_state = hass.states.get( - "device_tracker.{}".format(dev_id)) - - if device_tracker_state is not None: - acc = device_tracker_state.attributes.get("gps_accuracy") - lat = device_tracker_state.attributes.get("latitude") - lon = device_tracker_state.attributes.get("longitude") - kwargs['gps_accuracy'] = acc - kwargs['gps'] = (lat, lon) - - # the battery state applies to the tracking device, not the beacon - # kwargs location is the beacon's configured lat/lon - kwargs.pop('battery', None) - for beacon in self.mobile_beacons_active[dev_id]: - kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) - kwargs['host_name'] = beacon - await self.async_see(**kwargs) - - @HANDLERS.register('location') async def async_handle_location_message(hass, context, message): """Handle a location message.""" @@ -485,6 +349,8 @@ async def async_handle_message(hass, context, message): """Handle an OwnTracks message.""" msgtype = message.get('_type') + _LOGGER.debug("Received %s", message) + handler = HANDLERS.get(msgtype, async_handle_unsupported_msg) await handler(hass, context, message) diff --git a/homeassistant/components/device_tracker/owntracks_http.py b/homeassistant/components/device_tracker/owntracks_http.py deleted file mode 100644 index b9f379e7534..00000000000 --- a/homeassistant/components/device_tracker/owntracks_http.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Device tracker platform that adds support for OwnTracks over HTTP. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.owntracks_http/ -""" -import json -import logging -import re - -from aiohttp.web import Response -import voluptuous as vol - -# pylint: disable=unused-import -from homeassistant.components.device_tracker.owntracks import ( # NOQA - PLATFORM_SCHEMA, REQUIREMENTS, async_handle_message, context_from_config) -from homeassistant.const import CONF_WEBHOOK_ID -import homeassistant.helpers.config_validation as cv - -DEPENDENCIES = ['webhook'] - -_LOGGER = logging.getLogger(__name__) - -EVENT_RECEIVED = 'owntracks_http_webhook_received' -EVENT_RESPONSE = 'owntracks_http_webhook_response_' - -DOMAIN = 'device_tracker.owntracks_http' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_WEBHOOK_ID): cv.string -}) - - -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up OwnTracks HTTP component.""" - context = context_from_config(async_see, config) - - subscription = context.mqtt_topic - topic = re.sub('/#$', '', subscription) - - async def handle_webhook(hass, webhook_id, request): - """Handle webhook callback.""" - headers = request.headers - data = dict() - - if 'X-Limit-U' in headers: - data['user'] = headers['X-Limit-U'] - elif 'u' in request.query: - data['user'] = request.query['u'] - else: - return Response( - body=json.dumps({'error': 'You need to supply username.'}), - content_type="application/json" - ) - - if 'X-Limit-D' in headers: - data['device'] = headers['X-Limit-D'] - elif 'd' in request.query: - data['device'] = request.query['d'] - else: - return Response( - body=json.dumps({'error': 'You need to supply device name.'}), - content_type="application/json" - ) - - message = await request.json() - - message['topic'] = '{}/{}/{}'.format(topic, data['user'], - data['device']) - - try: - await async_handle_message(hass, context, message) - return Response(body=json.dumps([]), status=200, - content_type="application/json") - except ValueError: - _LOGGER.error("Received invalid JSON") - return None - - hass.components.webhook.async_register( - 'owntracks', 'OwnTracks', config['webhook_id'], handle_webhook) - - return True diff --git a/homeassistant/components/owntracks/.translations/en.json b/homeassistant/components/owntracks/.translations/en.json new file mode 100644 index 00000000000..a34077a0a83 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to preferences -> connection. Change the following settings:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left -> settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." + }, + "step": { + "user": { + "description": "Are you sure you want to set up OwnTracks?", + "title": "Set up OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py new file mode 100644 index 00000000000..a5da7f5fc48 --- /dev/null +++ b/homeassistant/components/owntracks/__init__.py @@ -0,0 +1,219 @@ +"""Component for OwnTracks.""" +from collections import defaultdict +import json +import logging +import re + +from aiohttp.web import json_response +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.components import mqtt +from homeassistant.setup import async_when_setup +import homeassistant.helpers.config_validation as cv + +from .config_flow import CONF_SECRET + +DOMAIN = "owntracks" +REQUIREMENTS = ['libnacl==1.6.1'] +DEPENDENCIES = ['device_tracker', 'webhook'] + +CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' +CONF_WAYPOINT_IMPORT = 'waypoints' +CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' +CONF_MQTT_TOPIC = 'mqtt_topic' +CONF_REGION_MAPPING = 'region_mapping' +CONF_EVENTS_ONLY = 'events_only' +BEACON_DEV_ID = 'beacon' + +DEFAULT_OWNTRACKS_TOPIC = 'owntracks/#' + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN, default={}): { + vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), + vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, + vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean, + vol.Optional(CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC): + mqtt.valid_subscribe_topic, + vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All( + cv.ensure_list, [cv.string]), + vol.Optional(CONF_SECRET): vol.Any( + vol.Schema({vol.Optional(cv.string): cv.string}), + cv.string), + vol.Optional(CONF_REGION_MAPPING, default={}): dict, + vol.Optional(CONF_WEBHOOK_ID): cv.string, + } +}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Initialize OwnTracks component.""" + hass.data[DOMAIN] = { + 'config': config[DOMAIN] + } + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={} + )) + + return True + + +async def async_setup_entry(hass, entry): + """Set up OwnTracks entry.""" + config = hass.data[DOMAIN]['config'] + max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) + waypoint_import = config.get(CONF_WAYPOINT_IMPORT) + waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) + secret = config.get(CONF_SECRET) or entry.data[CONF_SECRET] + region_mapping = config.get(CONF_REGION_MAPPING) + events_only = config.get(CONF_EVENTS_ONLY) + mqtt_topic = config.get(CONF_MQTT_TOPIC) + + context = OwnTracksContext(hass, secret, max_gps_accuracy, + waypoint_import, waypoint_whitelist, + region_mapping, events_only, mqtt_topic) + + webhook_id = config.get(CONF_WEBHOOK_ID) or entry.data[CONF_WEBHOOK_ID] + + hass.data[DOMAIN]['context'] = context + + async_when_setup(hass, 'mqtt', async_connect_mqtt) + + hass.components.webhook.async_register( + DOMAIN, 'OwnTracks', webhook_id, handle_webhook) + + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + entry, 'device_tracker')) + + return True + + +async def async_connect_mqtt(hass, component): + """Subscribe to MQTT topic.""" + context = hass.data[DOMAIN]['context'] + + async def async_handle_mqtt_message(topic, payload, qos): + """Handle incoming OwnTracks message.""" + try: + message = json.loads(payload) + except ValueError: + # If invalid JSON + _LOGGER.error("Unable to parse payload as JSON: %s", payload) + return + + message['topic'] = topic + hass.helpers.dispatcher.async_dispatcher_send( + DOMAIN, hass, context, message) + + await hass.components.mqtt.async_subscribe( + context.mqtt_topic, async_handle_mqtt_message, 1) + + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle webhook callback.""" + context = hass.data[DOMAIN]['context'] + message = await request.json() + + # Android doesn't populate topic + if 'topic' not in message: + headers = request.headers + user = headers.get('X-Limit-U') + device = headers.get('X-Limit-D', user) + + if user is None: + _LOGGER.warning('Set a username in Connection -> Identification') + return json_response( + {'error': 'You need to supply username.'}, + status=400 + ) + + topic_base = re.sub('/#$', '', context.mqtt_topic) + message['topic'] = '{}/{}/{}'.format(topic_base, user, device) + + hass.helpers.dispatcher.async_dispatcher_send( + DOMAIN, hass, context, message) + return json_response([]) + + +class OwnTracksContext: + """Hold the current OwnTracks context.""" + + def __init__(self, hass, secret, max_gps_accuracy, import_waypoints, + waypoint_whitelist, region_mapping, events_only, mqtt_topic): + """Initialize an OwnTracks context.""" + self.hass = hass + self.secret = secret + self.max_gps_accuracy = max_gps_accuracy + self.mobile_beacons_active = defaultdict(set) + self.regions_entered = defaultdict(list) + self.import_waypoints = import_waypoints + self.waypoint_whitelist = waypoint_whitelist + self.region_mapping = region_mapping + self.events_only = events_only + self.mqtt_topic = mqtt_topic + + @callback + def async_valid_accuracy(self, message): + """Check if we should ignore this message.""" + acc = message.get('acc') + + if acc is None: + return False + + try: + acc = float(acc) + except ValueError: + return False + + if acc == 0: + _LOGGER.warning( + "Ignoring %s update because GPS accuracy is zero: %s", + message['_type'], message) + return False + + if self.max_gps_accuracy is not None and \ + acc > self.max_gps_accuracy: + _LOGGER.info("Ignoring %s update because expected GPS " + "accuracy %s is not met: %s", + message['_type'], self.max_gps_accuracy, + message) + return False + + return True + + async def async_see(self, **data): + """Send a see message to the device tracker.""" + await self.hass.components.device_tracker.async_see(**data) + + async def async_see_beacons(self, hass, dev_id, kwargs_param): + """Set active beacons to the current location.""" + kwargs = kwargs_param.copy() + + # Mobile beacons should always be set to the location of the + # tracking device. I get the device state and make the necessary + # changes to kwargs. + device_tracker_state = hass.states.get( + "device_tracker.{}".format(dev_id)) + + if device_tracker_state is not None: + acc = device_tracker_state.attributes.get("gps_accuracy") + lat = device_tracker_state.attributes.get("latitude") + lon = device_tracker_state.attributes.get("longitude") + kwargs['gps_accuracy'] = acc + kwargs['gps'] = (lat, lon) + + # the battery state applies to the tracking device, not the beacon + # kwargs location is the beacon's configured lat/lon + kwargs.pop('battery', None) + for beacon in self.mobile_beacons_active[dev_id]: + kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) + kwargs['host_name'] = beacon + await self.async_see(**kwargs) diff --git a/homeassistant/components/owntracks/config_flow.py b/homeassistant/components/owntracks/config_flow.py new file mode 100644 index 00000000000..88362946428 --- /dev/null +++ b/homeassistant/components/owntracks/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for OwnTracks.""" +from homeassistant import config_entries +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.auth.util import generate_secret + +CONF_SECRET = 'secret' + + +def supports_encryption(): + """Test if we support encryption.""" + try: + # pylint: disable=unused-variable + import libnacl # noqa + return True + except OSError: + return False + + +@config_entries.HANDLERS.register('owntracks') +class OwnTracksFlow(config_entries.ConfigFlow): + """Set up OwnTracks.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle a user initiated set up flow to create OwnTracks webhook.""" + if self._async_current_entries(): + return self.async_abort(reason='one_instance_allowed') + + if user_input is None: + return self.async_show_form( + step_id='user', + ) + + webhook_id = self.hass.components.webhook.async_generate_id() + webhook_url = \ + self.hass.components.webhook.async_generate_url(webhook_id) + + secret = generate_secret(16) + + if supports_encryption(): + secret_desc = ( + "The encryption key is {secret} " + "(on Android under preferences -> advanced)") + else: + secret_desc = ( + "Encryption is not supported because libsodium is not " + "installed.") + + return self.async_create_entry( + title="OwnTracks", + data={ + CONF_WEBHOOK_ID: webhook_id, + CONF_SECRET: secret + }, + description_placeholders={ + 'secret': secret_desc, + 'webhook_url': webhook_url, + 'android_url': + 'https://play.google.com/store/apps/details?' + 'id=org.owntracks.android', + 'ios_url': + 'https://itunes.apple.com/us/app/owntracks/id692424691?mt=8', + 'docs_url': + 'https://www.home-assistant.io/components/owntracks/' + } + ) + + async def async_step_import(self, user_input): + """Import a config flow from configuration.""" + webhook_id = self.hass.components.webhook.async_generate_id() + secret = generate_secret(16) + return self.async_create_entry( + title="OwnTracks", + data={ + CONF_WEBHOOK_ID: webhook_id, + CONF_SECRET: secret + } + ) diff --git a/homeassistant/components/owntracks/strings.json b/homeassistant/components/owntracks/strings.json new file mode 100644 index 00000000000..fcf7305d714 --- /dev/null +++ b/homeassistant/components/owntracks/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "title": "OwnTracks", + "step": { + "user": { + "title": "Set up OwnTracks", + "description": "Are you sure you want to set up OwnTracks?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to preferences -> connection. Change the following settings:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left -> settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." + } + } +} diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 42bc8b089da..2325f35822f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -149,6 +149,7 @@ FLOWS = [ 'mqtt', 'nest', 'openuv', + 'owntracks', 'point', 'rainmachine', 'simplisafe', diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 057843834c0..cc7c4284f9c 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -4,7 +4,7 @@ import logging.handlers from timeit import default_timer as timer from types import ModuleType -from typing import Optional, Dict, List +from typing import Awaitable, Callable, Optional, Dict, List from homeassistant import requirements, core, loader, config as conf_util from homeassistant.config import async_notify_setup_error @@ -248,3 +248,35 @@ async def async_process_deps_reqs( raise HomeAssistantError("Could not install all requirements.") processed.add(name) + + +@core.callback +def async_when_setup( + hass: core.HomeAssistant, component: str, + when_setup_cb: Callable[ + [core.HomeAssistant, str], Awaitable[None]]) -> None: + """Call a method when a component is setup.""" + async def when_setup() -> None: + """Call the callback.""" + try: + await when_setup_cb(hass, component) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error handling when_setup callback for %s', + component) + + # Running it in a new task so that it always runs after + if component in hass.config.components: + hass.async_create_task(when_setup()) + return + + unsub = None + + async def loaded_event(event: core.Event) -> None: + """Call the callback.""" + if event.data[ATTR_COMPONENT] != component: + return + + unsub() # type: ignore + await when_setup() + + unsub = hass.bus.async_listen(EVENT_COMPONENT_LOADED, loaded_event) diff --git a/requirements_all.txt b/requirements_all.txt index bc53dbce24e..197f9be02d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -558,8 +558,7 @@ konnected==0.1.4 # homeassistant.components.eufy lakeside==0.10 -# homeassistant.components.device_tracker.owntracks -# homeassistant.components.device_tracker.owntracks_http +# homeassistant.components.owntracks libnacl==1.6.1 # homeassistant.components.dyson diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 2d7397692f8..6f457f30ed0 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -4,12 +4,11 @@ from asynctest import patch import pytest from tests.common import ( - assert_setup_component, async_fire_mqtt_message, mock_coro, mock_component, - async_mock_mqtt_component) -import homeassistant.components.device_tracker.owntracks as owntracks + async_fire_mqtt_message, mock_coro, mock_component, + async_mock_mqtt_component, MockConfigEntry) +from homeassistant.components import owntracks from homeassistant.setup import async_setup_component -from homeassistant.components import device_tracker -from homeassistant.const import CONF_PLATFORM, STATE_NOT_HOME +from homeassistant.const import STATE_NOT_HOME USER = 'greg' DEVICE = 'phone' @@ -290,6 +289,25 @@ def setup_comp(hass): 'zone.outer', 'zoning', OUTER_ZONE) +async def setup_owntracks(hass, config, + ctx_cls=owntracks.OwnTracksContext): + """Set up OwnTracks.""" + await async_mock_mqtt_component(hass) + + MockConfigEntry(domain='owntracks', data={ + 'webhook_id': 'owntracks_test', + 'secret': 'abcd', + }).add_to_hass(hass) + + with patch('homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])), \ + patch('homeassistant.components.device_tracker.' + 'load_yaml_config_file', return_value=mock_coro({})), \ + patch.object(owntracks, 'OwnTracksContext', ctx_cls): + assert await async_setup_component( + hass, 'owntracks', {'owntracks': config}) + + @pytest.fixture def context(hass, setup_comp): """Set up the mocked context.""" @@ -306,20 +324,11 @@ def context(hass, setup_comp): context = orig_context(*args) return context - with patch('homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])), \ - patch('homeassistant.components.device_tracker.' - 'load_yaml_config_file', return_value=mock_coro({})), \ - patch.object(owntracks, 'OwnTracksContext', store_context), \ - assert_setup_component(1, device_tracker.DOMAIN): - assert hass.loop.run_until_complete(async_setup_component( - hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True, - CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] - }})) + hass.loop.run_until_complete(setup_owntracks(hass, { + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True, + CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] + }, store_context)) def get_context(): """Get the current context.""" @@ -1211,19 +1220,14 @@ async def test_waypoint_import_blacklist(hass, context): assert wayp is None -async def test_waypoint_import_no_whitelist(hass, context): +async def test_waypoint_import_no_whitelist(hass, config_context): """Test import of list of waypoints with no whitelist set.""" - async def mock_see(**kwargs): - """Fake see method for owntracks.""" - return - - test_config = { - CONF_PLATFORM: 'owntracks', + await setup_owntracks(hass, { CONF_MAX_GPS_ACCURACY: 200, CONF_WAYPOINT_IMPORT: True, CONF_MQTT_TOPIC: 'owntracks/#', - } - await owntracks.async_setup_scanner(hass, test_config, mock_see) + }) + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message) # Check if it made it into states @@ -1364,12 +1368,9 @@ def config_context(hass, setup_comp): mock_cipher) async def test_encrypted_payload(hass, config_context): """Test encrypted payload.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) + await setup_owntracks(hass, { + CONF_SECRET: TEST_SECRET_KEY, + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert_location_latitude(hass, LOCATION_MESSAGE['lat']) @@ -1378,13 +1379,11 @@ async def test_encrypted_payload(hass, config_context): mock_cipher) async def test_encrypted_payload_topic_key(hass, config_context): """Test encrypted payload with a topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: TEST_SECRET_KEY, - }}}) + await setup_owntracks(hass, { + CONF_SECRET: { + LOCATION_TOPIC: TEST_SECRET_KEY, + } + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert_location_latitude(hass, LOCATION_MESSAGE['lat']) @@ -1394,12 +1393,10 @@ async def test_encrypted_payload_topic_key(hass, config_context): async def test_encrypted_payload_no_key(hass, config_context): """Test encrypted payload with no key, .""" assert hass.states.get(DEVICE_TRACKER_STATE) is None - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - # key missing - }}) + await setup_owntracks(hass, { + CONF_SECRET: { + } + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1408,12 +1405,9 @@ async def test_encrypted_payload_no_key(hass, config_context): mock_cipher) async def test_encrypted_payload_wrong_key(hass, config_context): """Test encrypted payload with wrong key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: 'wrong key', - }}) + await setup_owntracks(hass, { + CONF_SECRET: 'wrong key', + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1422,13 +1416,11 @@ async def test_encrypted_payload_wrong_key(hass, config_context): mock_cipher) async def test_encrypted_payload_wrong_topic_key(hass, config_context): """Test encrypted payload with wrong topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: 'wrong key' - }}}) + await setup_owntracks(hass, { + CONF_SECRET: { + LOCATION_TOPIC: 'wrong key' + }, + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1437,13 +1429,10 @@ async def test_encrypted_payload_wrong_topic_key(hass, config_context): mock_cipher) async def test_encrypted_payload_no_topic_key(hass, config_context): """Test encrypted payload with no topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' - }}}) + await setup_owntracks(hass, { + CONF_SECRET: { + 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' + }}) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1456,12 +1445,9 @@ async def test_encrypted_payload_libsodium(hass, config_context): pytest.skip("libnacl/libsodium is not installed") return - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) + await setup_owntracks(hass, { + CONF_SECRET: TEST_SECRET_KEY, + }) await send_message(hass, LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) assert_location_latitude(hass, LOCATION_MESSAGE['lat']) @@ -1469,12 +1455,9 @@ async def test_encrypted_payload_libsodium(hass, config_context): async def test_customized_mqtt_topic(hass, config_context): """Test subscribing to a custom mqtt topic.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_MQTT_TOPIC: 'mytracks/#', - }}) + await setup_owntracks(hass, { + CONF_MQTT_TOPIC: 'mytracks/#', + }) topic = 'mytracks/{}/{}'.format(USER, DEVICE) @@ -1484,14 +1467,11 @@ async def test_customized_mqtt_topic(hass, config_context): async def test_region_mapping(hass, config_context): """Test region to zone mapping.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_REGION_MAPPING: { - 'foo': 'inner' - }, - }}) + await setup_owntracks(hass, { + CONF_REGION_MAPPING: { + 'foo': 'inner' + }, + }) hass.states.async_set( 'zone.inner', 'zoning', INNER_ZONE) diff --git a/tests/components/owntracks/__init__.py b/tests/components/owntracks/__init__.py new file mode 100644 index 00000000000..a95431913b2 --- /dev/null +++ b/tests/components/owntracks/__init__.py @@ -0,0 +1 @@ +"""Tests for OwnTracks component.""" diff --git a/tests/components/owntracks/test_config_flow.py b/tests/components/owntracks/test_config_flow.py new file mode 100644 index 00000000000..079fdfafea0 --- /dev/null +++ b/tests/components/owntracks/test_config_flow.py @@ -0,0 +1 @@ +"""Tests for OwnTracks config flow.""" diff --git a/tests/components/device_tracker/test_owntracks_http.py b/tests/components/owntracks/test_init.py similarity index 51% rename from tests/components/device_tracker/test_owntracks_http.py rename to tests/components/owntracks/test_init.py index a49f30c6839..ee79c8b9e10 100644 --- a/tests/components/device_tracker/test_owntracks_http.py +++ b/tests/components/owntracks/test_init.py @@ -1,14 +1,11 @@ """Test the owntracks_http platform.""" import asyncio -from unittest.mock import patch -import os import pytest -from homeassistant.components import device_tracker from homeassistant.setup import async_setup_component -from tests.common import mock_component, mock_coro +from tests.common import mock_component, MockConfigEntry MINIMAL_LOCATION_MESSAGE = { '_type': 'location', @@ -36,38 +33,33 @@ LOCATION_MESSAGE = { } -@pytest.fixture(autouse=True) -def owntracks_http_cleanup(hass): - """Remove known_devices.yaml.""" - try: - os.remove(hass.config.path(device_tracker.YAML_DEVICES)) - except OSError: - pass - - @pytest.fixture def mock_client(hass, aiohttp_client): """Start the Hass HTTP component.""" mock_component(hass, 'group') mock_component(hass, 'zone') - with patch('homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])): - hass.loop.run_until_complete( - async_setup_component(hass, 'device_tracker', { - 'device_tracker': { - 'platform': 'owntracks_http', - 'webhook_id': 'owntracks_test' - } - })) + mock_component(hass, 'device_tracker') + + MockConfigEntry(domain='owntracks', data={ + 'webhook_id': 'owntracks_test', + 'secret': 'abcd', + }).add_to_hass(hass) + hass.loop.run_until_complete(async_setup_component(hass, 'owntracks', {})) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @asyncio.coroutine def test_handle_valid_message(mock_client): """Test that we forward messages correctly to OwnTracks.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?' - 'u=test&d=test', - json=LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) assert resp.status == 200 @@ -78,9 +70,14 @@ def test_handle_valid_message(mock_client): @asyncio.coroutine def test_handle_valid_minimal_message(mock_client): """Test that we forward messages correctly to OwnTracks.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?' - 'u=test&d=test', - json=MINIMAL_LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=MINIMAL_LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) assert resp.status == 200 @@ -91,8 +88,14 @@ def test_handle_valid_minimal_message(mock_client): @asyncio.coroutine def test_handle_value_error(mock_client): """Test we don't disclose that this is a valid webhook.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test' - '?u=test&d=test', json='') + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json='', + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) assert resp.status == 200 @@ -103,10 +106,15 @@ def test_handle_value_error(mock_client): @asyncio.coroutine def test_returns_error_missing_username(mock_client): """Test that an error is returned when username is missing.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?d=test', - json=LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-d': 'Pixel', + } + ) - assert resp.status == 200 + assert resp.status == 400 json = yield from resp.json() assert json == {'error': 'You need to supply username.'} @@ -115,10 +123,27 @@ def test_returns_error_missing_username(mock_client): @asyncio.coroutine def test_returns_error_missing_device(mock_client): """Test that an error is returned when device name is missing.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?u=test', - json=LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + } + ) assert resp.status == 200 json = yield from resp.json() - assert json == {'error': 'You need to supply device name.'} + assert json == [] + + +async def test_config_flow_import(hass): + """Test that we automatically create a config flow.""" + assert not hass.config_entries.async_entries('owntracks') + assert await async_setup_component(hass, 'owntracks', { + 'owntracks': { + + } + }) + await hass.async_block_till_done() + assert hass.config_entries.async_entries('owntracks') diff --git a/tests/test_setup.py b/tests/test_setup.py index 29712f40ebc..2e44ee539d7 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -9,7 +9,8 @@ import logging import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_COMPONENT_LOADED) import homeassistant.config as config_util from homeassistant import setup, loader import homeassistant.util.dt as dt_util @@ -459,3 +460,35 @@ def test_platform_no_warn_slow(hass): hass, 'test_component1', {}) assert result assert not mock_call.called + + +async def test_when_setup_already_loaded(hass): + """Test when setup.""" + calls = [] + + async def mock_callback(hass, component): + """Mock callback.""" + calls.append(component) + + setup.async_when_setup(hass, 'test', mock_callback) + await hass.async_block_till_done() + assert calls == [] + + hass.config.components.add('test') + hass.bus.async_fire(EVENT_COMPONENT_LOADED, { + 'component': 'test' + }) + await hass.async_block_till_done() + assert calls == ['test'] + + # Event listener should be gone + hass.bus.async_fire(EVENT_COMPONENT_LOADED, { + 'component': 'test' + }) + await hass.async_block_till_done() + assert calls == ['test'] + + # Should be called right away + setup.async_when_setup(hass, 'test', mock_callback) + await hass.async_block_till_done() + assert calls == ['test', 'test'] From 311c796da7e9dac2c74aaec380d7541ec8de318e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Nov 2018 22:17:37 +0100 Subject: [PATCH 14/47] Default to on if logged in (#18766) --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/cloud/prefs.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index b968850668d..4f4b0c582fc 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -248,7 +248,7 @@ class Cloud: info = await self.hass.async_add_job(load_config) - await self.prefs.async_initialize(not info) + await self.prefs.async_initialize(bool(info)) if info is None: return diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index d29b356cfc0..7e1ec6a0232 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -27,6 +27,7 @@ class CloudPreferences: PREF_ENABLE_GOOGLE: logged_in, PREF_GOOGLE_ALLOW_UNLOCK: False, } + await self._store.async_save(prefs) self._prefs = prefs From 05915775e37fa2be565dda6348947ed63dd37f92 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Nov 2018 22:47:37 +0100 Subject: [PATCH 15/47] Bumped version to 0.83.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9fc6d61cb33..585d9ff3b83 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -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 1364114dc189a33ab96c9837e71470b10ad43c1d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Nov 2018 10:57:40 +0100 Subject: [PATCH 16/47] Bumped version to 0.83.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 585d9ff3b83..dc00267cdf8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '0b3' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 2ba521caf867da5f9639a89810e35a2ff4a3112a Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Fri, 23 Nov 2018 01:56:18 -0600 Subject: [PATCH 17/47] Add websocket call for adding item to shopping-list (#18623) --- homeassistant/components/shopping_list.py | 20 ++++++++++++ tests/components/test_shopping_list.py | 38 +++++++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index 45650ece621..650d23fe1df 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -38,12 +38,19 @@ SERVICE_ITEM_SCHEMA = vol.Schema({ }) WS_TYPE_SHOPPING_LIST_ITEMS = 'shopping_list/items' +WS_TYPE_SHOPPING_LIST_ADD_ITEM = 'shopping_list/items/add' SCHEMA_WEBSOCKET_ITEMS = \ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_SHOPPING_LIST_ITEMS }) +SCHEMA_WEBSOCKET_ADD_ITEM = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SHOPPING_LIST_ADD_ITEM, + vol.Required('name'): str + }) + @asyncio.coroutine def async_setup(hass, config): @@ -103,6 +110,10 @@ def async_setup(hass, config): WS_TYPE_SHOPPING_LIST_ITEMS, websocket_handle_items, SCHEMA_WEBSOCKET_ITEMS) + hass.components.websocket_api.async_register_command( + WS_TYPE_SHOPPING_LIST_ADD_ITEM, + websocket_handle_add, + SCHEMA_WEBSOCKET_ADD_ITEM) return True @@ -276,3 +287,12 @@ def websocket_handle_items(hass, connection, msg): """Handle get shopping_list items.""" connection.send_message(websocket_api.result_message( msg['id'], hass.data[DOMAIN].items)) + + +@callback +def websocket_handle_add(hass, connection, msg): + """Handle add item to shopping_list.""" + item = hass.data[DOMAIN].async_add(msg['name']) + hass.bus.async_fire(EVENT) + connection.send_message(websocket_api.result_message( + msg['id'], item)) diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index e64b9a5ae26..44714138eb3 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -228,7 +228,7 @@ def test_api_clear_completed(hass, aiohttp_client): @asyncio.coroutine -def test_api_create(hass, aiohttp_client): +def test_deprecated_api_create(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -249,7 +249,7 @@ def test_api_create(hass, aiohttp_client): @asyncio.coroutine -def test_api_create_fail(hass, aiohttp_client): +def test_deprecated_api_create_fail(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -260,3 +260,37 @@ def test_api_create_fail(hass, aiohttp_client): assert resp.status == 400 assert len(hass.data['shopping_list'].items) == 0 + + +async def test_ws_add_item(hass, hass_ws_client): + """Test adding shopping_list item websocket command.""" + await async_setup_component(hass, 'shopping_list', {}) + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items/add', + 'name': 'soda', + }) + msg = await client.receive_json() + assert msg['success'] is True + data = msg['result'] + assert data['name'] == 'soda' + assert data['complete'] is False + items = hass.data['shopping_list'].items + assert len(items) == 1 + assert items[0]['name'] == 'soda' + assert items[0]['complete'] is False + + +async def test_ws_add_item_fail(hass, hass_ws_client): + """Test adding shopping_list item failure websocket command.""" + await async_setup_component(hass, 'shopping_list', {}) + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items/add', + 'name': 123, + }) + msg = await client.receive_json() + assert msg['success'] is False + assert len(hass.data['shopping_list'].items) == 0 From 601389302a08bc4a15ed36652d00350f37691e24 Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Mon, 26 Nov 2018 02:59:53 -0600 Subject: [PATCH 18/47] Convert shopping-list update to WebSockets (#18713) * Convert shopping-list update to WebSockets * Update shopping_list.py * Update test_shopping_list.py --- homeassistant/components/shopping_list.py | 31 ++++++++ tests/components/test_shopping_list.py | 86 ++++++++++++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index 650d23fe1df..ad4680982b4 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -39,6 +39,7 @@ SERVICE_ITEM_SCHEMA = vol.Schema({ WS_TYPE_SHOPPING_LIST_ITEMS = 'shopping_list/items' WS_TYPE_SHOPPING_LIST_ADD_ITEM = 'shopping_list/items/add' +WS_TYPE_SHOPPING_LIST_UPDATE_ITEM = 'shopping_list/items/update' SCHEMA_WEBSOCKET_ITEMS = \ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ @@ -51,6 +52,14 @@ SCHEMA_WEBSOCKET_ADD_ITEM = \ vol.Required('name'): str }) +SCHEMA_WEBSOCKET_UPDATE_ITEM = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SHOPPING_LIST_UPDATE_ITEM, + vol.Required('item_id'): str, + vol.Optional('name'): str, + vol.Optional('complete'): bool + }) + @asyncio.coroutine def async_setup(hass, config): @@ -114,6 +123,10 @@ def async_setup(hass, config): WS_TYPE_SHOPPING_LIST_ADD_ITEM, websocket_handle_add, SCHEMA_WEBSOCKET_ADD_ITEM) + hass.components.websocket_api.async_register_command( + WS_TYPE_SHOPPING_LIST_UPDATE_ITEM, + websocket_handle_update, + SCHEMA_WEBSOCKET_UPDATE_ITEM) return True @@ -296,3 +309,21 @@ def websocket_handle_add(hass, connection, msg): hass.bus.async_fire(EVENT) connection.send_message(websocket_api.result_message( msg['id'], item)) + + +@websocket_api.async_response +async def websocket_handle_update(hass, connection, msg): + """Handle update shopping_list item.""" + msg_id = msg.pop('id') + item_id = msg.pop('item_id') + msg.pop('type') + data = msg + + try: + item = hass.data[DOMAIN].async_update(item_id, data) + hass.bus.async_fire(EVENT) + connection.send_message(websocket_api.result_message( + msg_id, item)) + except KeyError: + connection.send_message(websocket_api.error_message( + msg_id, 'item_not_found', 'Item not found')) diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index 44714138eb3..c2899f6b753 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -110,7 +110,7 @@ async def test_ws_get_items(hass, hass_ws_client): @asyncio.coroutine -def test_api_update(hass, aiohttp_client): +def test_deprecated_api_update(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -164,6 +164,61 @@ def test_api_update(hass, aiohttp_client): } +async def test_ws_update_item(hass, hass_ws_client): + """Test update shopping_list item websocket command.""" + await async_setup_component(hass, 'shopping_list', {}) + await intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + await intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} + ) + + beer_id = hass.data['shopping_list'].items[0]['id'] + wine_id = hass.data['shopping_list'].items[1]['id'] + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items/update', + 'item_id': beer_id, + 'name': 'soda' + }) + msg = await client.receive_json() + assert msg['success'] is True + data = msg['result'] + assert data == { + 'id': beer_id, + 'name': 'soda', + 'complete': False + } + await client.send_json({ + 'id': 6, + 'type': 'shopping_list/items/update', + 'item_id': wine_id, + 'complete': True + }) + msg = await client.receive_json() + assert msg['success'] is True + data = msg['result'] + assert data == { + 'id': wine_id, + 'name': 'wine', + 'complete': True + } + + beer, wine = hass.data['shopping_list'].items + assert beer == { + 'id': beer_id, + 'name': 'soda', + 'complete': False + } + assert wine == { + 'id': wine_id, + 'name': 'wine', + 'complete': True + } + + @asyncio.coroutine def test_api_update_fails(hass, aiohttp_client): """Test the API.""" @@ -190,6 +245,35 @@ def test_api_update_fails(hass, aiohttp_client): assert resp.status == 400 +async def test_ws_update_item_fail(hass, hass_ws_client): + """Test failure of update shopping_list item websocket command.""" + await async_setup_component(hass, 'shopping_list', {}) + await intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items/update', + 'item_id': 'non_existing', + 'name': 'soda' + }) + msg = await client.receive_json() + assert msg['success'] is False + data = msg['error'] + assert data == { + 'code': 'item_not_found', + 'message': 'Item not found' + } + await client.send_json({ + 'id': 6, + 'type': 'shopping_list/items/update', + 'name': 123, + }) + msg = await client.receive_json() + assert msg['success'] is False + + @asyncio.coroutine def test_api_clear_completed(hass, aiohttp_client): """Test the API.""" From ff33d34b818d43cb6f414c567beeba0fc00013f5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 27 Nov 2018 10:41:44 +0100 Subject: [PATCH 19/47] Legacy api fix (#18733) * Set user for API password requests * Fix tests * Fix typing --- .../auth/providers/legacy_api_password.py | 29 +++++++++-- homeassistant/components/http/auth.py | 5 ++ tests/components/alexa/test_intent.py | 4 +- tests/components/alexa/test_smart_home.py | 12 ++--- tests/components/conftest.py | 39 ++++++++++++++- tests/components/hassio/conftest.py | 2 +- tests/components/http/test_auth.py | 11 +++-- tests/components/http/test_init.py | 2 +- tests/components/test_api.py | 22 ++++++--- tests/components/test_conversation.py | 12 ++--- tests/components/test_history.py | 4 +- tests/components/test_shopping_list.py | 24 +++++----- tests/components/test_spaceapi.py | 4 +- tests/components/test_system_log.py | 48 +++++++++---------- tests/components/test_webhook.py | 4 +- 15 files changed, 148 insertions(+), 74 deletions(-) diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index 111b9e7d39f..6cdb12b7157 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -4,16 +4,19 @@ Support Legacy API password auth provider. It will be removed when auth system production ready """ import hmac -from typing import Any, Dict, Optional, cast +from typing import Any, Dict, Optional, cast, TYPE_CHECKING import voluptuous as vol -from homeassistant.components.http import HomeAssistantHTTP # noqa: F401 -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow -from ..models import Credentials, UserMeta +from .. import AuthManager +from ..models import Credentials, UserMeta, User + +if TYPE_CHECKING: + from homeassistant.components.http import HomeAssistantHTTP # noqa: F401 USER_SCHEMA = vol.Schema({ @@ -31,6 +34,24 @@ class InvalidAuthError(HomeAssistantError): """Raised when submitting invalid authentication.""" +async def async_get_user(hass: HomeAssistant) -> User: + """Return the legacy API password user.""" + auth = cast(AuthManager, hass.auth) # type: ignore + found = None + + for prv in auth.auth_providers: + if prv.type == 'legacy_api_password': + found = prv + break + + if found is None: + raise ValueError('Legacy API password provider not found') + + return await auth.async_get_or_create_user( + await found.async_get_or_create_credentials({}) + ) + + @AUTH_PROVIDERS.register('legacy_api_password') class LegacyApiPasswordAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 64ee7fb8a3f..1f9782bb4fe 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -10,6 +10,7 @@ import jwt from homeassistant.core import callback from homeassistant.const import HTTP_HEADER_HA_AUTH +from homeassistant.auth.providers import legacy_api_password from homeassistant.auth.util import generate_secret from homeassistant.util import dt as dt_util @@ -78,12 +79,16 @@ def setup_auth(app, trusted_networks, use_auth, request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))): # A valid auth header has been set authenticated = True + request['hass_user'] = await legacy_api_password.async_get_user( + app['hass']) elif (legacy_auth and DATA_API_PASSWORD in request.query and hmac.compare_digest( api_password.encode('utf-8'), request.query[DATA_API_PASSWORD].encode('utf-8'))): authenticated = True + request['hass_user'] = await legacy_api_password.async_get_user( + app['hass']) elif _is_trusted_ip(request, trusted_networks): authenticated = True diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index d15c7ccbb34..ab84dd2a3bc 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -23,7 +23,7 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @pytest.fixture -def alexa_client(loop, hass, aiohttp_client): +def alexa_client(loop, hass, hass_client): """Initialize a Home Assistant server for testing this module.""" @callback def mock_service(call): @@ -95,7 +95,7 @@ def alexa_client(loop, hass, aiohttp_client): }, } })) - return loop.run_until_complete(aiohttp_client(hass.http.app)) + return loop.run_until_complete(hass_client()) def _intent_req(client, data=None): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 766075f8eb5..3cfb8068177 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1437,10 +1437,10 @@ async def test_unsupported_domain(hass): assert not msg['payload']['endpoints'] -async def do_http_discovery(config, hass, aiohttp_client): +async def do_http_discovery(config, hass, hass_client): """Submit a request to the Smart Home HTTP API.""" await async_setup_component(hass, alexa.DOMAIN, config) - http_client = await aiohttp_client(hass.http.app) + http_client = await hass_client() request = get_new_request('Alexa.Discovery', 'Discover') response = await http_client.post( @@ -1450,7 +1450,7 @@ async def do_http_discovery(config, hass, aiohttp_client): return response -async def test_http_api(hass, aiohttp_client): +async def test_http_api(hass, hass_client): """With `smart_home:` HTTP API is exposed.""" config = { 'alexa': { @@ -1458,7 +1458,7 @@ async def test_http_api(hass, aiohttp_client): } } - response = await do_http_discovery(config, hass, aiohttp_client) + response = await do_http_discovery(config, hass, hass_client) response_data = await response.json() # Here we're testing just the HTTP view glue -- details of discovery are @@ -1466,12 +1466,12 @@ async def test_http_api(hass, aiohttp_client): assert response_data['event']['header']['name'] == 'Discover.Response' -async def test_http_api_disabled(hass, aiohttp_client): +async def test_http_api_disabled(hass, hass_client): """Without `smart_home:`, the HTTP API is disabled.""" config = { 'alexa': {} } - response = await do_http_discovery(config, hass, aiohttp_client) + response = await do_http_discovery(config, hass, hass_client) assert response.status == 404 diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 97f2044baea..5d5a964b2ce 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY +from homeassistant.auth.providers import legacy_api_password, homeassistant from homeassistant.setup import async_setup_component from homeassistant.components.websocket_api.http import URL from homeassistant.components.websocket_api.auth import ( @@ -80,7 +81,7 @@ def hass_access_token(hass, hass_admin_user): @pytest.fixture -def hass_admin_user(hass): +def hass_admin_user(hass, local_auth): """Return a Home Assistant admin user.""" admin_group = hass.loop.run_until_complete(hass.auth.async_get_group( GROUP_ID_ADMIN)) @@ -88,8 +89,42 @@ def hass_admin_user(hass): @pytest.fixture -def hass_read_only_user(hass): +def hass_read_only_user(hass, local_auth): """Return a Home Assistant read only user.""" read_only_group = hass.loop.run_until_complete(hass.auth.async_get_group( GROUP_ID_READ_ONLY)) return MockUser(groups=[read_only_group]).add_to_hass(hass) + + +@pytest.fixture +def legacy_auth(hass): + """Load legacy API password provider.""" + prv = legacy_api_password.LegacyApiPasswordAuthProvider( + hass, hass.auth._store, { + 'type': 'legacy_api_password' + } + ) + hass.auth._providers[(prv.type, prv.id)] = prv + + +@pytest.fixture +def local_auth(hass): + """Load local auth provider.""" + prv = homeassistant.HassAuthProvider( + hass, hass.auth._store, { + 'type': 'homeassistant' + } + ) + hass.auth._providers[(prv.type, prv.id)] = prv + + +@pytest.fixture +def hass_client(hass, aiohttp_client, hass_access_token): + """Return an authenticated HTTP client.""" + async def auth_client(): + """Return an authenticated client.""" + return await aiohttp_client(hass.http.app, headers={ + 'Authorization': "Bearer {}".format(hass_access_token) + }) + + return auth_client diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index f9ad1c578de..435de6d1edf 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -27,7 +27,7 @@ def hassio_env(): @pytest.fixture -def hassio_client(hassio_env, hass, aiohttp_client): +def hassio_client(hassio_env, hass, aiohttp_client, legacy_auth): """Create mock hassio http client.""" with patch('homeassistant.components.hassio.HassIO.update_hass_api', Mock(return_value=mock_coro({"result": "ok"}))), \ diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 2746abcf15c..979bfc28689 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -83,7 +83,8 @@ async def test_access_without_password(app, aiohttp_client): assert resp.status == 200 -async def test_access_with_password_in_header(app, aiohttp_client): +async def test_access_with_password_in_header(app, aiohttp_client, + legacy_auth): """Test access with password in header.""" setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) @@ -97,7 +98,7 @@ async def test_access_with_password_in_header(app, aiohttp_client): assert req.status == 401 -async def test_access_with_password_in_query(app, aiohttp_client): +async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth): """Test access with password in URL.""" setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) @@ -219,7 +220,8 @@ async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client): "{} should be trusted".format(remote_addr) -async def test_auth_active_blocked_api_password_access(app, aiohttp_client): +async def test_auth_active_blocked_api_password_access( + app, aiohttp_client, legacy_auth): """Test access using api_password should be blocked when auth.active.""" setup_auth(app, [], True, api_password=API_PASSWORD) client = await aiohttp_client(app) @@ -239,7 +241,8 @@ async def test_auth_active_blocked_api_password_access(app, aiohttp_client): assert req.status == 401 -async def test_auth_legacy_support_api_password_access(app, aiohttp_client): +async def test_auth_legacy_support_api_password_access( + app, aiohttp_client, legacy_auth): """Test access using api_password if auth.support_legacy.""" setup_auth(app, [], True, support_legacy=True, api_password=API_PASSWORD) client = await aiohttp_client(app) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 9f6441c5238..1c1afe711c6 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -124,7 +124,7 @@ async def test_api_no_base_url(hass): assert hass.config.api.base_url == 'http://127.0.0.1:8123' -async def test_not_log_password(hass, aiohttp_client, caplog): +async def test_not_log_password(hass, aiohttp_client, caplog, legacy_auth): """Test access with password doesn't get logged.""" assert await async_setup_component(hass, 'api', { 'http': { diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 3ebfa05a3d3..0bc89292855 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -16,12 +16,10 @@ from tests.common import async_mock_service @pytest.fixture -def mock_api_client(hass, aiohttp_client, hass_access_token): +def mock_api_client(hass, hass_client): """Start the Hass HTTP component and return admin API client.""" hass.loop.run_until_complete(async_setup_component(hass, 'api', {})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app, headers={ - 'Authorization': 'Bearer {}'.format(hass_access_token) - })) + return hass.loop.run_until_complete(hass_client()) @asyncio.coroutine @@ -408,7 +406,7 @@ def _listen_count(hass): async def test_api_error_log(hass, aiohttp_client, hass_access_token, - hass_admin_user): + hass_admin_user, legacy_auth): """Test if we can fetch the error log.""" hass.data[DATA_LOGGING] = '/some/path' await async_setup_component(hass, 'api', { @@ -566,5 +564,17 @@ async def test_rendering_template_admin(hass, mock_api_client, hass_admin_user): """Test rendering a template requires admin.""" hass_admin_user.groups = [] - resp = await mock_api_client.post('/api/template') + resp = await mock_api_client.post(const.URL_API_TEMPLATE) + assert resp.status == 401 + + +async def test_rendering_template_legacy_user( + hass, mock_api_client, aiohttp_client, legacy_auth): + """Test rendering a template with legacy API password.""" + hass.states.async_set('sensor.temperature', 10) + client = await aiohttp_client(hass.http.app) + resp = await client.post( + const.URL_API_TEMPLATE, + json={"template": '{{ states.sensor.temperature.state }}'} + ) assert resp.status == 401 diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index 7934e016281..2aa1f499a76 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -90,7 +90,7 @@ async def test_register_before_setup(hass): assert intent.text_input == 'I would like the Grolsch beer' -async def test_http_processing_intent(hass, aiohttp_client): +async def test_http_processing_intent(hass, hass_client): """Test processing intent via HTTP API.""" class TestIntentHandler(intent.IntentHandler): """Test Intent Handler.""" @@ -120,7 +120,7 @@ async def test_http_processing_intent(hass, aiohttp_client): }) assert result - client = await aiohttp_client(hass.http.app) + client = await hass_client() resp = await client.post('/api/conversation/process', json={ 'text': 'I would like the Grolsch beer' }) @@ -244,7 +244,7 @@ async def test_toggle_intent(hass, sentence): assert call.data == {'entity_id': 'light.kitchen'} -async def test_http_api(hass, aiohttp_client): +async def test_http_api(hass, hass_client): """Test the HTTP conversation API.""" result = await component.async_setup(hass, {}) assert result @@ -252,7 +252,7 @@ async def test_http_api(hass, aiohttp_client): result = await async_setup_component(hass, 'conversation', {}) assert result - client = await aiohttp_client(hass.http.app) + client = await hass_client() hass.states.async_set('light.kitchen', 'off') calls = async_mock_service(hass, HASS_DOMAIN, 'turn_on') @@ -268,7 +268,7 @@ async def test_http_api(hass, aiohttp_client): assert call.data == {'entity_id': 'light.kitchen'} -async def test_http_api_wrong_data(hass, aiohttp_client): +async def test_http_api_wrong_data(hass, hass_client): """Test the HTTP conversation API.""" result = await component.async_setup(hass, {}) assert result @@ -276,7 +276,7 @@ async def test_http_api_wrong_data(hass, aiohttp_client): result = await async_setup_component(hass, 'conversation', {}) assert result - client = await aiohttp_client(hass.http.app) + client = await hass_client() resp = await client.post('/api/conversation/process', json={ 'text': 123 diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 9764af1592c..641dff3b4e6 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -515,13 +515,13 @@ class TestComponentHistory(unittest.TestCase): return zero, four, states -async def test_fetch_period_api(hass, aiohttp_client): +async def test_fetch_period_api(hass, hass_client): """Test the fetch period view for history.""" await hass.async_add_job(init_recorder_component, hass) await async_setup_component(hass, 'history', {}) await hass.components.recorder.wait_connection_ready() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) - client = await aiohttp_client(hass.http.app) + client = await hass_client() response = await client.get( '/api/history/period/{}'.format(dt_util.utcnow().isoformat())) assert response.status == 200 diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index c2899f6b753..1e89287bcc1 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -55,7 +55,7 @@ def test_recent_items_intent(hass): @asyncio.coroutine -def test_deprecated_api_get_all(hass, aiohttp_client): +def test_deprecated_api_get_all(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -66,7 +66,7 @@ def test_deprecated_api_get_all(hass, aiohttp_client): hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} ) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.get('/api/shopping_list') assert resp.status == 200 @@ -110,7 +110,7 @@ async def test_ws_get_items(hass, hass_ws_client): @asyncio.coroutine -def test_deprecated_api_update(hass, aiohttp_client): +def test_deprecated_api_update(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -124,7 +124,7 @@ def test_deprecated_api_update(hass, aiohttp_client): beer_id = hass.data['shopping_list'].items[0]['id'] wine_id = hass.data['shopping_list'].items[1]['id'] - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/shopping_list/item/{}'.format(beer_id), json={ 'name': 'soda' @@ -220,7 +220,7 @@ async def test_ws_update_item(hass, hass_ws_client): @asyncio.coroutine -def test_api_update_fails(hass, aiohttp_client): +def test_api_update_fails(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -228,7 +228,7 @@ def test_api_update_fails(hass, aiohttp_client): hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} ) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/shopping_list/non_existing', json={ 'name': 'soda' @@ -275,7 +275,7 @@ async def test_ws_update_item_fail(hass, hass_ws_client): @asyncio.coroutine -def test_api_clear_completed(hass, aiohttp_client): +def test_api_clear_completed(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -289,7 +289,7 @@ def test_api_clear_completed(hass, aiohttp_client): beer_id = hass.data['shopping_list'].items[0]['id'] wine_id = hass.data['shopping_list'].items[1]['id'] - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() # Mark beer as completed resp = yield from client.post( @@ -312,11 +312,11 @@ def test_api_clear_completed(hass, aiohttp_client): @asyncio.coroutine -def test_deprecated_api_create(hass, aiohttp_client): +def test_deprecated_api_create(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post('/api/shopping_list/item', json={ 'name': 'soda' }) @@ -333,11 +333,11 @@ def test_deprecated_api_create(hass, aiohttp_client): @asyncio.coroutine -def test_deprecated_api_create_fail(hass, aiohttp_client): +def test_deprecated_api_create_fail(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post('/api/shopping_list/item', json={ 'name': 1234 }) diff --git a/tests/components/test_spaceapi.py b/tests/components/test_spaceapi.py index e7e7d158a31..61bb009ff8f 100644 --- a/tests/components/test_spaceapi.py +++ b/tests/components/test_spaceapi.py @@ -56,7 +56,7 @@ SENSOR_OUTPUT = { @pytest.fixture -def mock_client(hass, aiohttp_client): +def mock_client(hass, hass_client): """Start the Home Assistant HTTP component.""" with patch('homeassistant.components.spaceapi', return_value=mock_coro(True)): @@ -70,7 +70,7 @@ def mock_client(hass, aiohttp_client): hass.states.async_set('test.hum1', 88, attributes={'unit_of_measurement': '%'}) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return hass.loop.run_until_complete(hass_client()) async def test_spaceapi_get(hass, mock_client): diff --git a/tests/components/test_system_log.py b/tests/components/test_system_log.py index 5d48fd88127..6afd792be9c 100644 --- a/tests/components/test_system_log.py +++ b/tests/components/test_system_log.py @@ -14,9 +14,9 @@ BASIC_CONFIG = { } -async def get_error_log(hass, aiohttp_client, expected_count): +async def get_error_log(hass, hass_client, expected_count): """Fetch all entries from system_log via the API.""" - client = await aiohttp_client(hass.http.app) + client = await hass_client() resp = await client.get('/api/error/all') assert resp.status == 200 @@ -45,37 +45,37 @@ def get_frame(name): return (name, None, None, None) -async def test_normal_logs(hass, aiohttp_client): +async def test_normal_logs(hass, hass_client): """Test that debug and info are not logged.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.debug('debug') _LOGGER.info('info') # Assert done by get_error_log - await get_error_log(hass, aiohttp_client, 0) + await get_error_log(hass, hass_client, 0) -async def test_exception(hass, aiohttp_client): +async def test_exception(hass, hass_client): """Test that exceptions are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _generate_and_log_exception('exception message', 'log message') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, 'exception message', 'log message', 'ERROR') -async def test_warning(hass, aiohttp_client): +async def test_warning(hass, hass_client): """Test that warning are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.warning('warning message') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, '', 'warning message', 'WARNING') -async def test_error(hass, aiohttp_client): +async def test_error(hass, hass_client): """Test that errors are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error('error message') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, '', 'error message', 'ERROR') @@ -121,26 +121,26 @@ async def test_error_posted_as_event(hass): assert_log(events[0].data, '', 'error message', 'ERROR') -async def test_critical(hass, aiohttp_client): +async def test_critical(hass, hass_client): """Test that critical are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.critical('critical message') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, '', 'critical message', 'CRITICAL') -async def test_remove_older_logs(hass, aiohttp_client): +async def test_remove_older_logs(hass, hass_client): """Test that older logs are rotated out.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error('error message 1') _LOGGER.error('error message 2') _LOGGER.error('error message 3') - log = await get_error_log(hass, aiohttp_client, 2) + log = await get_error_log(hass, hass_client, 2) assert_log(log[0], '', 'error message 3', 'ERROR') assert_log(log[1], '', 'error message 2', 'ERROR') -async def test_clear_logs(hass, aiohttp_client): +async def test_clear_logs(hass, hass_client): """Test that the log can be cleared via a service call.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error('error message') @@ -151,7 +151,7 @@ async def test_clear_logs(hass, aiohttp_client): await hass.async_block_till_done() # Assert done by get_error_log - await get_error_log(hass, aiohttp_client, 0) + await get_error_log(hass, hass_client, 0) async def test_write_log(hass): @@ -197,13 +197,13 @@ async def test_write_choose_level(hass): assert logger.method_calls[0] == ('debug', ('test_message',)) -async def test_unknown_path(hass, aiohttp_client): +async def test_unknown_path(hass, hass_client): """Test error logged from unknown path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.findCaller = MagicMock( return_value=('unknown_path', 0, None, None)) _LOGGER.error('error message') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert log['source'] == 'unknown_path' @@ -222,31 +222,31 @@ def log_error_from_test_path(path): _LOGGER.error('error message') -async def test_homeassistant_path(hass, aiohttp_client): +async def test_homeassistant_path(hass, hass_client): """Test error logged from homeassistant path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch('homeassistant.components.system_log.HOMEASSISTANT_PATH', new=['venv_path/homeassistant']): log_error_from_test_path( 'venv_path/homeassistant/component/component.py') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert log['source'] == 'component/component.py' -async def test_config_path(hass, aiohttp_client): +async def test_config_path(hass, hass_client): """Test error logged from config path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch.object(hass.config, 'config_dir', new='config'): log_error_from_test_path('config/custom_component/test.py') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert log['source'] == 'custom_component/test.py' -async def test_netdisco_path(hass, aiohttp_client): +async def test_netdisco_path(hass, hass_client): """Test error logged from netdisco path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch.dict('sys.modules', netdisco=MagicMock(__path__=['venv_path/netdisco'])): log_error_from_test_path('venv_path/netdisco/disco_component.py') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert log['source'] == 'disco_component.py' diff --git a/tests/components/test_webhook.py b/tests/components/test_webhook.py index c16fef3e059..e67cf7481cc 100644 --- a/tests/components/test_webhook.py +++ b/tests/components/test_webhook.py @@ -7,10 +7,10 @@ from homeassistant.setup import async_setup_component @pytest.fixture -def mock_client(hass, aiohttp_client): +def mock_client(hass, hass_client): """Create http client for webhooks.""" hass.loop.run_until_complete(async_setup_component(hass, 'webhook', {})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return hass.loop.run_until_complete(hass_client()) async def test_unregistering_webhook(hass, mock_client): From f1c5e756ff159877a7a2fc842c95e6fe670fabca Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Thu, 29 Nov 2018 20:16:39 +0100 Subject: [PATCH 20/47] Fix logbook domain filter - alexa, homekit (#18790) --- homeassistant/components/logbook.py | 6 ++++++ tests/components/test_logbook.py | 27 +++++++++++++++++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index c7a37411f1e..b6f434a82ad 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -445,6 +445,12 @@ def _exclude_events(events, entities_filter): domain = event.data.get(ATTR_DOMAIN) entity_id = event.data.get(ATTR_ENTITY_ID) + elif event.event_type == EVENT_ALEXA_SMART_HOME: + domain = 'alexa' + + elif event.event_type == EVENT_HOMEKIT_CHANGED: + domain = DOMAIN_HOMEKIT + if not entity_id and domain: entity_id = "%s." % (domain, ) diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index ae1e3d1d51a..0d204773241 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -242,9 +242,11 @@ class TestComponentLogbook(unittest.TestCase): config = logbook.CONFIG_SCHEMA({ ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_EXCLUDE: { - logbook.CONF_DOMAINS: ['switch', ]}}}) + logbook.CONF_DOMAINS: ['switch', 'alexa', DOMAIN_HOMEKIT]}}}) events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_START), eventA, eventB), + (ha.Event(EVENT_HOMEASSISTANT_START), + ha.Event(EVENT_ALEXA_SMART_HOME), + ha.Event(EVENT_HOMEKIT_CHANGED), eventA, eventB), logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) @@ -325,22 +327,35 @@ class TestComponentLogbook(unittest.TestCase): pointA = dt_util.utcnow() pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES) + event_alexa = ha.Event(EVENT_ALEXA_SMART_HOME, {'request': { + 'namespace': 'Alexa.Discovery', + 'name': 'Discover', + }}) + event_homekit = ha.Event(EVENT_HOMEKIT_CHANGED, { + ATTR_ENTITY_ID: 'lock.front_door', + ATTR_DISPLAY_NAME: 'Front Door', + ATTR_SERVICE: 'lock', + }) + eventA = self.create_state_changed_event(pointA, entity_id, 10) eventB = self.create_state_changed_event(pointB, entity_id2, 20) config = logbook.CONFIG_SCHEMA({ ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_INCLUDE: { - logbook.CONF_DOMAINS: ['sensor', ]}}}) + logbook.CONF_DOMAINS: ['sensor', 'alexa', DOMAIN_HOMEKIT]}}}) events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_START), eventA, eventB), + (ha.Event(EVENT_HOMEASSISTANT_START), + event_alexa, event_homekit, eventA, eventB), logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) - assert 2 == len(entries) + assert 4 == len(entries) self.assert_entry(entries[0], name='Home Assistant', message='started', domain=ha.DOMAIN) - self.assert_entry(entries[1], pointB, 'blu', domain='sensor', + self.assert_entry(entries[1], name='Amazon Alexa', domain='alexa') + self.assert_entry(entries[2], name='HomeKit', domain=DOMAIN_HOMEKIT) + self.assert_entry(entries[3], pointB, 'blu', domain='sensor', entity_id=entity_id2) def test_include_exclude_events(self): From 0ca67bf6f7ebc196a04b47f6b80a192f9791e27d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Nov 2018 22:26:19 +0100 Subject: [PATCH 21/47] Make auth backwards compat again (#18792) * Made auth not backwards compat * Fix tests --- homeassistant/auth/auth_store.py | 3 ++- tests/auth/test_auth_store.py | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index cf82c40a4d3..bad1bdcf913 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -462,10 +462,11 @@ class AuthStore: for group in self._groups.values(): g_dict = { 'id': group.id, + # Name not read for sys groups. Kept here for backwards compat + 'name': group.name } # type: Dict[str, Any] if group.id not in (GROUP_ID_READ_ONLY, GROUP_ID_ADMIN): - g_dict['name'] = group.name g_dict['policy'] = group.policy groups.append(g_dict) diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index b76d68fbeac..7e9df869a04 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -199,13 +199,22 @@ async def test_loading_empty_data(hass, hass_storage): assert len(users) == 0 -async def test_system_groups_only_store_id(hass, hass_storage): - """Test that for system groups we only store the ID.""" +async def test_system_groups_store_id_and_name(hass, hass_storage): + """Test that for system groups we store the ID and name. + + Name is stored so that we remain backwards compat with < 0.82. + """ store = auth_store.AuthStore(hass) await store._async_load() data = store._data_to_save() assert len(data['users']) == 0 assert data['groups'] == [ - {'id': auth_store.GROUP_ID_ADMIN}, - {'id': auth_store.GROUP_ID_READ_ONLY}, + { + 'id': auth_store.GROUP_ID_ADMIN, + 'name': auth_store.GROUP_NAME_ADMIN, + }, + { + 'id': auth_store.GROUP_ID_READ_ONLY, + 'name': auth_store.GROUP_NAME_READ_ONLY, + }, ] From fa9a200e3c2baf8024aaa1f419196b59ac2f1bb4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Nov 2018 22:17:01 +0100 Subject: [PATCH 22/47] Render the secret (#18793) --- homeassistant/components/owntracks/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/owntracks/config_flow.py b/homeassistant/components/owntracks/config_flow.py index 88362946428..8cf19e84bcd 100644 --- a/homeassistant/components/owntracks/config_flow.py +++ b/homeassistant/components/owntracks/config_flow.py @@ -40,8 +40,8 @@ class OwnTracksFlow(config_entries.ConfigFlow): if supports_encryption(): secret_desc = ( - "The encryption key is {secret} " - "(on Android under preferences -> advanced)") + "The encryption key is {} " + "(on Android under preferences -> advanced)".format(secret)) else: secret_desc = ( "Encryption is not supported because libsodium is not " From 7fa5f0721880a26a8e4e5754f897dc1ccbb5de1d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Nov 2018 22:26:06 +0100 Subject: [PATCH 23/47] Fix race condition in group.set (#18796) --- homeassistant/components/group/__init__.py | 9 ++++++++- tests/helpers/test_entity_component.py | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 4dd3571e69c..15a3816c559 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -207,6 +207,13 @@ async def async_setup(hass, config): DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=RELOAD_SERVICE_SCHEMA) + service_lock = asyncio.Lock() + + async def locked_service_handler(service): + """Handle a service with an async lock.""" + async with service_lock: + await groups_service_handler(service) + async def groups_service_handler(service): """Handle dynamic group service functions.""" object_id = service.data[ATTR_OBJECT_ID] @@ -284,7 +291,7 @@ async def async_setup(hass, config): await component.async_remove_entity(entity_id) hass.services.async_register( - DOMAIN, SERVICE_SET, groups_service_handler, + DOMAIN, SERVICE_SET, locked_service_handler, schema=SET_SERVICE_SCHEMA) hass.services.async_register( diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 2bef8c0b53e..7562a38d268 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -431,3 +431,24 @@ async def test_update_entity(hass): assert len(entity.async_update_ha_state.mock_calls) == 2 assert entity.async_update_ha_state.mock_calls[-1][1][0] is True + + +async def test_set_service_race(hass): + """Test race condition on setting service.""" + exception = False + + def async_loop_exception_handler(_, _2) -> None: + """Handle all exception inside the core loop.""" + nonlocal exception + exception = True + + hass.loop.set_exception_handler(async_loop_exception_handler) + + await async_setup_component(hass, 'group', {}) + component = EntityComponent(_LOGGER, DOMAIN, hass, group_name='yo') + + for i in range(2): + hass.async_create_task(component.async_add_entities([MockEntity()])) + + await hass.async_block_till_done() + assert not exception From 5a6ac9ee7219512fa0a364fa10accd64db8d00cf Mon Sep 17 00:00:00 2001 From: Eric Nagley Date: Thu, 29 Nov 2018 16:24:53 -0500 Subject: [PATCH 24/47] BUGFIX: handle extra fan speeds. (#18799) * BUGFIX: add support for extra fan speeds. * Drop extra fan speeds. Remove catch all, drop missing fan speeds. * fix self.speed_synonyms call. Remove un-needed keys() call --- homeassistant/components/google_assistant/trait.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index d32dd91a3c1..61231a7894d 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -712,6 +712,8 @@ class FanSpeedTrait(_Trait): modes = self.state.attributes.get(fan.ATTR_SPEED_LIST, []) speeds = [] for mode in modes: + if mode not in self.speed_synonyms: + continue speed = { "speed_name": mode, "speed_values": [{ From f2b818658f4c020b99473257781db923943253c3 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 29 Nov 2018 14:24:32 -0700 Subject: [PATCH 25/47] Bumped py17track to 2.1.0 (#18804) --- homeassistant/components/sensor/seventeentrack.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/seventeentrack.py b/homeassistant/components/sensor/seventeentrack.py index 7ad0e453760..b4c869e7267 100644 --- a/homeassistant/components/sensor/seventeentrack.py +++ b/homeassistant/components/sensor/seventeentrack.py @@ -17,7 +17,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle, slugify -REQUIREMENTS = ['py17track==2.0.2'] +REQUIREMENTS = ['py17track==2.1.0'] _LOGGER = logging.getLogger(__name__) ATTR_DESTINATION_COUNTRY = 'destination_country' diff --git a/requirements_all.txt b/requirements_all.txt index 197f9be02d0..dc0f7e8679f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -804,7 +804,7 @@ py-melissa-climate==2.0.0 py-synology==0.2.0 # homeassistant.components.sensor.seventeentrack -py17track==2.0.2 +py17track==2.1.0 # homeassistant.components.hdmi_cec pyCEC==0.4.13 From d9124b182ac822405403509922b5b0d64cce67df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 29 Nov 2018 22:28:27 +0100 Subject: [PATCH 26/47] Remove self from update function in rainmachine (#18807) --- homeassistant/components/binary_sensor/rainmachine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/binary_sensor/rainmachine.py b/homeassistant/components/binary_sensor/rainmachine.py index 4a671fc9512..efae9330365 100644 --- a/homeassistant/components/binary_sensor/rainmachine.py +++ b/homeassistant/components/binary_sensor/rainmachine.py @@ -74,7 +74,7 @@ class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice): async def async_added_to_hass(self): """Register callbacks.""" @callback - def update(self): + def update(): """Update the state.""" self.async_schedule_update_ha_state(True) From 31d7221c90245dd3b79289b6a27734bd0fc084f4 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 29 Nov 2018 14:47:41 -0700 Subject: [PATCH 27/47] Remove additional self from update function in RainMachine (#18810) --- homeassistant/components/sensor/rainmachine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/rainmachine.py b/homeassistant/components/sensor/rainmachine.py index 5131b25510a..86a97bc291c 100644 --- a/homeassistant/components/sensor/rainmachine.py +++ b/homeassistant/components/sensor/rainmachine.py @@ -77,7 +77,7 @@ class RainMachineSensor(RainMachineEntity): async def async_added_to_hass(self): """Register callbacks.""" @callback - def update(self): + def update(): """Update the state.""" self.async_schedule_update_ha_state(True) From 0467d0563acc0f213082bdc2c1066de847d6a978 Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Thu, 29 Nov 2018 22:57:05 +0100 Subject: [PATCH 28/47] Hotfix for crash with virtual devices (#18808) * Quickfix for crash with virtual devices Added try/except to critical loops of processing Reinforced read_devices, map_device_to_type and update processing * oops --- homeassistant/components/fibaro.py | 90 +++++++++++++++++------------- 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/fibaro.py b/homeassistant/components/fibaro.py index c9dd19b4bc8..85bd5c3c018 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -103,29 +103,31 @@ class FibaroController(): """Handle change report received from the HomeCenter.""" callback_set = set() for change in state.get('changes', []): - dev_id = change.pop('id') - for property_name, value in change.items(): - if property_name == 'log': - if value and value != "transfer OK": - _LOGGER.debug("LOG %s: %s", - self._device_map[dev_id].friendly_name, - value) + try: + dev_id = change.pop('id') + if dev_id not in self._device_map.keys(): continue - if property_name == 'logTemp': - continue - if property_name in self._device_map[dev_id].properties: - self._device_map[dev_id].properties[property_name] = \ - value - _LOGGER.debug("<- %s.%s = %s", - self._device_map[dev_id].ha_id, - property_name, - str(value)) - else: - _LOGGER.warning("Error updating %s data of %s, not found", - property_name, - self._device_map[dev_id].ha_id) - if dev_id in self._callbacks: - callback_set.add(dev_id) + device = self._device_map[dev_id] + for property_name, value in change.items(): + if property_name == 'log': + if value and value != "transfer OK": + _LOGGER.debug("LOG %s: %s", + device.friendly_name, value) + continue + if property_name == 'logTemp': + continue + if property_name in device.properties: + device.properties[property_name] = \ + value + _LOGGER.debug("<- %s.%s = %s", device.ha_id, + property_name, str(value)) + else: + _LOGGER.warning("%s.%s not found", device.ha_id, + property_name) + if dev_id in self._callbacks: + callback_set.add(dev_id) + except (ValueError, KeyError): + pass for item in callback_set: self._callbacks[item]() @@ -137,8 +139,12 @@ class FibaroController(): def _map_device_to_type(device): """Map device to HA device type.""" # Use our lookup table to identify device type - device_type = FIBARO_TYPEMAP.get( - device.type, FIBARO_TYPEMAP.get(device.baseType)) + if 'type' in device: + device_type = FIBARO_TYPEMAP.get(device.type) + elif 'baseType' in device: + device_type = FIBARO_TYPEMAP.get(device.baseType) + else: + device_type = None # We can also identify device type by its capabilities if device_type is None: @@ -156,8 +162,7 @@ class FibaroController(): # Switches that control lights should show up as lights if device_type == 'switch' and \ - 'isLight' in device.properties and \ - device.properties.isLight == 'true': + device.properties.get('isLight', 'false') == 'true': device_type = 'light' return device_type @@ -165,26 +170,31 @@ class FibaroController(): """Read and process the device list.""" devices = self._client.devices.list() self._device_map = {} - for device in devices: - if device.roomID == 0: - room_name = 'Unknown' - else: - room_name = self._room_map[device.roomID].name - device.friendly_name = room_name + ' ' + device.name - device.ha_id = '{}_{}_{}'.format( - slugify(room_name), slugify(device.name), device.id) - self._device_map[device.id] = device self.fibaro_devices = defaultdict(list) - for device in self._device_map.values(): - if device.enabled and \ - (not device.isPlugin or self._import_plugins): - device.mapped_type = self._map_device_to_type(device) + for device in devices: + try: + if device.roomID == 0: + room_name = 'Unknown' + else: + room_name = self._room_map[device.roomID].name + device.friendly_name = room_name + ' ' + device.name + device.ha_id = '{}_{}_{}'.format( + slugify(room_name), slugify(device.name), device.id) + if device.enabled and \ + ('isPlugin' not in device or + (not device.isPlugin or self._import_plugins)): + device.mapped_type = self._map_device_to_type(device) + else: + device.mapped_type = None if device.mapped_type: + self._device_map[device.id] = device self.fibaro_devices[device.mapped_type].append(device) else: - _LOGGER.debug("%s (%s, %s) not mapped", + _LOGGER.debug("%s (%s, %s) not used", device.ha_id, device.type, device.baseType) + except (KeyError, ValueError): + pass def setup(hass, config): From 163c881ced824af536364657f336c48bf4df19c6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Nov 2018 22:58:06 +0100 Subject: [PATCH 29/47] Bumped version to 0.83.1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index dc00267cdf8..6b69609be22 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -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 440614dd9d3c7874c47b57c431db2af93d7e954c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Nov 2018 20:55:21 +0100 Subject: [PATCH 30/47] Use proper signals (#18613) * Emulated Hue not use deprecated handler * Remove no longer needed workaround * Add middleware directly * Dont always load the ban config file * Update homeassistant/components/http/ban.py Co-Authored-By: balloob * Update __init__.py --- .../components/emulated_hue/__init__.py | 27 +++++++++---------- homeassistant/components/http/__init__.py | 7 +---- homeassistant/components/http/auth.py | 6 +---- homeassistant/components/http/ban.py | 17 ++++++------ homeassistant/components/http/real_ip.py | 6 +---- tests/components/conftest.py | 10 ++++++- tests/components/http/test_ban.py | 15 ++++++----- 7 files changed, 43 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 5f1d61dd602..9c0df0f9f03 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -97,8 +97,8 @@ async def async_setup(hass, yaml_config): app._on_startup.freeze() await app.startup() - handler = None - server = None + runner = None + site = None DescriptionXmlView(config).register(app, app.router) HueUsernameView().register(app, app.router) @@ -115,25 +115,24 @@ async def async_setup(hass, yaml_config): async def stop_emulated_hue_bridge(event): """Stop the emulated hue bridge.""" upnp_listener.stop() - if server: - server.close() - await server.wait_closed() - await app.shutdown() - if handler: - await handler.shutdown(10) - await app.cleanup() + if site: + await site.stop() + if runner: + await runner.cleanup() async def start_emulated_hue_bridge(event): """Start the emulated hue bridge.""" upnp_listener.start() - nonlocal handler - nonlocal server + nonlocal site + nonlocal runner - handler = app.make_handler(loop=hass.loop) + runner = web.AppRunner(app) + await runner.setup() + + site = web.TCPSite(runner, config.host_ip_addr, config.listen_port) try: - server = await hass.loop.create_server( - handler, config.host_ip_addr, config.listen_port) + await site.start() except OSError as error: _LOGGER.error("Failed to create HTTP server at port %d: %s", config.listen_port, error) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 1b22f8e62d4..7180002430a 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -302,12 +302,6 @@ class HomeAssistantHTTP: async def start(self): """Start the aiohttp server.""" - # We misunderstood the startup signal. You're not allowed to change - # anything during startup. Temp workaround. - # pylint: disable=protected-access - self.app._on_startup.freeze() - await self.app.startup() - if self.ssl_certificate: try: if self.ssl_profile == SSL_INTERMEDIATE: @@ -335,6 +329,7 @@ class HomeAssistantHTTP: # However in Home Assistant components can be discovered after boot. # This will now raise a RunTimeError. # To work around this we now prevent the router from getting frozen + # pylint: disable=protected-access self.app._router.freeze = lambda: None self.runner = web.AppRunner(self.app) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 1f9782bb4fe..0e943b33fb8 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -101,11 +101,7 @@ def setup_auth(app, trusted_networks, use_auth, request[KEY_AUTHENTICATED] = authenticated return await handler(request) - async def auth_startup(app): - """Initialize auth middleware when app starts up.""" - app.middlewares.append(auth_middleware) - - app.on_startup.append(auth_startup) + app.middlewares.append(auth_middleware) def _is_trusted_ip(request, trusted_networks): diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 2a25de96edc..d6d7168ce6d 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -9,7 +9,7 @@ from aiohttp.web import middleware from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized import voluptuous as vol -from homeassistant.core import callback +from homeassistant.core import callback, HomeAssistant from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv @@ -36,13 +36,14 @@ SCHEMA_IP_BAN_ENTRY = vol.Schema({ @callback def setup_bans(hass, app, login_threshold): """Create IP Ban middleware for the app.""" + app.middlewares.append(ban_middleware) + app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int) + app[KEY_LOGIN_THRESHOLD] = login_threshold + async def ban_startup(app): """Initialize bans when app starts up.""" - app.middlewares.append(ban_middleware) - app[KEY_BANNED_IPS] = await hass.async_add_job( - load_ip_bans_config, hass.config.path(IP_BANS_FILE)) - app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int) - app[KEY_LOGIN_THRESHOLD] = login_threshold + app[KEY_BANNED_IPS] = await async_load_ip_bans_config( + hass, hass.config.path(IP_BANS_FILE)) app.on_startup.append(ban_startup) @@ -149,7 +150,7 @@ class IpBan: self.banned_at = banned_at or datetime.utcnow() -def load_ip_bans_config(path: str): +async def async_load_ip_bans_config(hass: HomeAssistant, path: str): """Load list of banned IPs from config file.""" ip_list = [] @@ -157,7 +158,7 @@ def load_ip_bans_config(path: str): return ip_list try: - list_ = load_yaml_config_file(path) + list_ = await hass.async_add_executor_job(load_yaml_config_file, path) except HomeAssistantError as err: _LOGGER.error('Unable to load %s: %s', path, str(err)) return ip_list diff --git a/homeassistant/components/http/real_ip.py b/homeassistant/components/http/real_ip.py index f8adc815fde..27a8550ab8c 100644 --- a/homeassistant/components/http/real_ip.py +++ b/homeassistant/components/http/real_ip.py @@ -33,8 +33,4 @@ def setup_real_ip(app, use_x_forwarded_for, trusted_proxies): return await handler(request) - async def app_startup(app): - """Initialize bans when app starts up.""" - app.middlewares.append(real_ip_middleware) - - app.on_startup.append(app_startup) + app.middlewares.append(real_ip_middleware) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 5d5a964b2ce..110ba8d5ad6 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -10,7 +10,15 @@ from homeassistant.components.websocket_api.http import URL from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_OK, TYPE_AUTH_REQUIRED) -from tests.common import MockUser, CLIENT_ID +from tests.common import MockUser, CLIENT_ID, mock_coro + + +@pytest.fixture(autouse=True) +def prevent_io(): + """Fixture to prevent certain I/O from happening.""" + with patch('homeassistant.components.http.ban.async_load_ip_bans_config', + side_effect=lambda *args: mock_coro([])): + yield @pytest.fixture diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index a6a07928113..6624937da8d 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -16,6 +16,9 @@ from homeassistant.components.http.ban import ( from . import mock_real_ip +from tests.common import mock_coro + + BANNED_IPS = ['200.201.202.203', '100.64.0.2'] @@ -25,9 +28,9 @@ async def test_access_from_banned_ip(hass, aiohttp_client): setup_bans(hass, app, 5) set_real_ip = mock_real_ip(app) - with patch('homeassistant.components.http.ban.load_ip_bans_config', - return_value=[IpBan(banned_ip) for banned_ip - in BANNED_IPS]): + with patch('homeassistant.components.http.ban.async_load_ip_bans_config', + return_value=mock_coro([IpBan(banned_ip) for banned_ip + in BANNED_IPS])): client = await aiohttp_client(app) for remote_addr in BANNED_IPS: @@ -71,9 +74,9 @@ async def test_ip_bans_file_creation(hass, aiohttp_client): setup_bans(hass, app, 1) mock_real_ip(app)("200.201.202.204") - with patch('homeassistant.components.http.ban.load_ip_bans_config', - return_value=[IpBan(banned_ip) for banned_ip - in BANNED_IPS]): + with patch('homeassistant.components.http.ban.async_load_ip_bans_config', + return_value=mock_coro([IpBan(banned_ip) for banned_ip + in BANNED_IPS])): client = await aiohttp_client(app) m = mock_open() From 449cde539632c09dfbcbdc2f019b012fe6923607 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Fri, 30 Nov 2018 13:57:17 +0100 Subject: [PATCH 31/47] Revert change to MQTT discovery_hash introduced in #18169 (#18763) --- homeassistant/components/mqtt/discovery.py | 7 ++--- tests/components/binary_sensor/test_mqtt.py | 33 --------------------- 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index d91ab6ee445..bf83b173972 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -208,11 +208,8 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, if value[-1] == TOPIC_BASE and key.endswith('_topic'): payload[key] = "{}{}".format(value[:-1], base) - # If present, unique_id is used as the discovered object id. Otherwise, - # if present, the node_id will be included in the discovered object id - discovery_id = payload.get( - 'unique_id', ' '.join( - (node_id, object_id)) if node_id else object_id) + # If present, the node_id will be included in the discovered object id + discovery_id = ' '.join((node_id, object_id)) if node_id else object_id discovery_hash = (component, discovery_id) if payload: diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/binary_sensor/test_mqtt.py index 88bd39ebfe2..71d179211a2 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/binary_sensor/test_mqtt.py @@ -333,39 +333,6 @@ async def test_discovery_update_binary_sensor(hass, mqtt_mock, caplog): assert state is None -async def test_discovery_unique_id(hass, mqtt_mock, caplog): - """Test unique id option only creates one sensor per unique_id.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, 'homeassistant', {}, entry) - data1 = ( - '{ "name": "Beer",' - ' "state_topic": "test_topic",' - ' "unique_id": "TOTALLY_UNIQUE" }' - ) - data2 = ( - '{ "name": "Milk",' - ' "state_topic": "test_topic",' - ' "unique_id": "TOTALLY_DIFFERENT" }' - ) - async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', - data1) - await hass.async_block_till_done() - state = hass.states.get('binary_sensor.beer') - assert state is not None - assert state.name == 'Beer' - async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', - data2) - await hass.async_block_till_done() - await hass.async_block_till_done() - state = hass.states.get('binary_sensor.beer') - assert state is not None - assert state.name == 'Beer' - - state = hass.states.get('binary_sensor.milk') - assert state is not None - assert state.name == 'Milk' - - async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT binary sensor device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) From ada148eeae1da328f4e8b0eb377ae226575948ad Mon Sep 17 00:00:00 2001 From: Darren Foo Date: Fri, 30 Nov 2018 02:18:24 -0800 Subject: [PATCH 32/47] bump gtts-token to 1.1.3 (#18824) --- homeassistant/components/tts/google.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tts/google.py b/homeassistant/components/tts/google.py index 5e1da2595af..0d449083f72 100644 --- a/homeassistant/components/tts/google.py +++ b/homeassistant/components/tts/google.py @@ -17,7 +17,7 @@ import yarl from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['gTTS-token==1.1.2'] +REQUIREMENTS = ['gTTS-token==1.1.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index dc0f7e8679f..e4020ca41e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -406,7 +406,7 @@ freesms==0.1.2 fritzhome==1.0.4 # homeassistant.components.tts.google -gTTS-token==1.1.2 +gTTS-token==1.1.3 # homeassistant.components.sensor.gearbest gearbest_parser==1.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7223771891..f602c04bd79 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -72,7 +72,7 @@ feedparser==5.2.1 foobot_async==0.3.1 # homeassistant.components.tts.google -gTTS-token==1.1.2 +gTTS-token==1.1.3 # homeassistant.components.geo_location.geo_json_events # homeassistant.components.geo_location.nsw_rural_fire_service_feed From 80f2c2b12451698c6ba1608fa23eae813b520bc4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 30 Nov 2018 17:32:47 +0100 Subject: [PATCH 33/47] Always set hass_user (#18844) --- homeassistant/components/http/auth.py | 18 ++++++-- tests/components/conftest.py | 6 +++ tests/components/http/test_auth.py | 65 ++++++++++++++++++++++++--- 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 0e943b33fb8..ae6abf04c02 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -45,6 +45,7 @@ def setup_auth(app, trusted_networks, use_auth, support_legacy=False, api_password=None): """Create auth middleware for the app.""" old_auth_warning = set() + legacy_auth = (not use_auth or support_legacy) and api_password @middleware async def auth_middleware(request, handler): @@ -60,7 +61,6 @@ def setup_auth(app, trusted_networks, use_auth, request.path, request[KEY_REAL_IP]) old_auth_warning.add(request.path) - legacy_auth = (not use_auth or support_legacy) and api_password if (hdrs.AUTHORIZATION in request.headers and await async_validate_auth_header( request, api_password if legacy_auth else None)): @@ -91,6 +91,11 @@ def setup_auth(app, trusted_networks, use_auth, app['hass']) elif _is_trusted_ip(request, trusted_networks): + users = await app['hass'].auth.async_get_users() + for user in users: + if user.is_owner: + request['hass_user'] = user + break authenticated = True elif not use_auth and api_password is None: @@ -136,8 +141,9 @@ async def async_validate_auth_header(request, api_password=None): # If no space in authorization header return False + hass = request.app['hass'] + if auth_type == 'Bearer': - hass = request.app['hass'] refresh_token = await hass.auth.async_validate_access_token(auth_val) if refresh_token is None: return False @@ -157,8 +163,12 @@ async def async_validate_auth_header(request, api_password=None): if username != 'homeassistant': return False - return hmac.compare_digest(api_password.encode('utf-8'), - password.encode('utf-8')) + if not hmac.compare_digest(api_password.encode('utf-8'), + password.encode('utf-8')): + return False + + request['hass_user'] = await legacy_api_password.async_get_user(hass) + return True return False diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 110ba8d5ad6..d3cbdba63b4 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -88,6 +88,12 @@ def hass_access_token(hass, hass_admin_user): yield hass.auth.async_create_access_token(refresh_token) +@pytest.fixture +def hass_owner_user(hass, local_auth): + """Return a Home Assistant admin user.""" + return MockUser(is_owner=True).add_to_hass(hass) + + @pytest.fixture def hass_admin_user(hass, local_auth): """Return a Home Assistant admin user.""" diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 979bfc28689..222e8ced6e7 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -7,6 +7,7 @@ import pytest from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized +from homeassistant.auth.providers import legacy_api_password from homeassistant.components.http.auth import setup_auth, async_sign_path from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.real_ip import setup_real_ip @@ -84,29 +85,40 @@ async def test_access_without_password(app, aiohttp_client): async def test_access_with_password_in_header(app, aiohttp_client, - legacy_auth): + legacy_auth, hass): """Test access with password in header.""" setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) + user = await legacy_api_password.async_get_user(hass) req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: 'wrong-pass'}) assert req.status == 401 -async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth): +async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth, + hass): """Test access with password in URL.""" setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) + user = await legacy_api_password.async_get_user(hass) resp = await client.get('/', params={ 'api_password': API_PASSWORD }) assert resp.status == 200 + assert await resp.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } resp = await client.get('/') assert resp.status == 401 @@ -117,15 +129,20 @@ async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth): assert resp.status == 401 -async def test_basic_auth_works(app, aiohttp_client): +async def test_basic_auth_works(app, aiohttp_client, hass, legacy_auth): """Test access with basic authentication.""" setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) + user = await legacy_api_password.async_get_user(hass) req = await client.get( '/', auth=BasicAuth('homeassistant', API_PASSWORD)) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } req = await client.get( '/', @@ -145,7 +162,7 @@ async def test_basic_auth_works(app, aiohttp_client): assert req.status == 401 -async def test_access_with_trusted_ip(app2, aiohttp_client): +async def test_access_with_trusted_ip(app2, aiohttp_client, hass_owner_user): """Test access with an untrusted ip address.""" setup_auth(app2, TRUSTED_NETWORKS, False, api_password='some-pass') @@ -163,6 +180,10 @@ async def test_access_with_trusted_ip(app2, aiohttp_client): resp = await client.get('/') assert resp.status == 200, \ "{} should be trusted".format(remote_addr) + assert await resp.json() == { + 'refresh_token_id': None, + 'user_id': hass_owner_user.id, + } async def test_auth_active_access_with_access_token_in_header( @@ -171,18 +192,32 @@ async def test_auth_active_access_with_access_token_in_header( token = hass_access_token setup_auth(app, [], True, api_password=None) client = await aiohttp_client(app) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) req = await client.get( '/', headers={'Authorization': 'Bearer {}'.format(token)}) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': refresh_token.id, + 'user_id': refresh_token.user.id, + } req = await client.get( '/', headers={'AUTHORIZATION': 'Bearer {}'.format(token)}) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': refresh_token.id, + 'user_id': refresh_token.user.id, + } req = await client.get( '/', headers={'authorization': 'Bearer {}'.format(token)}) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': refresh_token.id, + 'user_id': refresh_token.user.id, + } req = await client.get( '/', headers={'Authorization': token}) @@ -200,7 +235,8 @@ async def test_auth_active_access_with_access_token_in_header( assert req.status == 401 -async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client): +async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client, + hass_owner_user): """Test access with an untrusted ip address.""" setup_auth(app2, TRUSTED_NETWORKS, True, api_password=None) @@ -218,6 +254,10 @@ async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client): resp = await client.get('/') assert resp.status == 200, \ "{} should be trusted".format(remote_addr) + assert await resp.json() == { + 'refresh_token_id': None, + 'user_id': hass_owner_user.id, + } async def test_auth_active_blocked_api_password_access( @@ -242,24 +282,37 @@ async def test_auth_active_blocked_api_password_access( async def test_auth_legacy_support_api_password_access( - app, aiohttp_client, legacy_auth): + app, aiohttp_client, legacy_auth, hass): """Test access using api_password if auth.support_legacy.""" setup_auth(app, [], True, support_legacy=True, api_password=API_PASSWORD) client = await aiohttp_client(app) + user = await legacy_api_password.async_get_user(hass) req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } resp = await client.get('/', params={ 'api_password': API_PASSWORD }) assert resp.status == 200 + assert await resp.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } req = await client.get( '/', auth=BasicAuth('homeassistant', API_PASSWORD)) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } async def test_auth_access_signed_path( From 474909b515dd4221fdeb93d63d91b52313d66824 Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Fri, 30 Nov 2018 17:23:25 +0100 Subject: [PATCH 34/47] Hotfix for Fibaro wall plug (#18845) Fibaro wall plug with a lamp plugged in was misrecognized as a color light, generating crashes in the update function. --- homeassistant/components/light/fibaro.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/fibaro.py b/homeassistant/components/light/fibaro.py index 96069d50335..7157dcfd31b 100644 --- a/homeassistant/components/light/fibaro.py +++ b/homeassistant/components/light/fibaro.py @@ -65,7 +65,8 @@ class FibaroLight(FibaroDevice, Light): self._update_lock = asyncio.Lock() if 'levelChange' in fibaro_device.interfaces: self._supported_flags |= SUPPORT_BRIGHTNESS - if 'color' in fibaro_device.properties: + if 'color' in fibaro_device.properties and \ + 'setColor' in fibaro_device.actions: self._supported_flags |= SUPPORT_COLOR if 'setW' in fibaro_device.actions: self._supported_flags |= SUPPORT_WHITE_VALUE @@ -168,7 +169,9 @@ class FibaroLight(FibaroDevice, Light): if self._supported_flags & SUPPORT_BRIGHTNESS: self._brightness = float(self.fibaro_device.properties.value) # Color handling - if self._supported_flags & SUPPORT_COLOR: + if self._supported_flags & SUPPORT_COLOR and \ + 'color' in self.fibaro_device.properties and \ + ',' in self.fibaro_device.properties.color: # Fibaro communicates the color as an 'R, G, B, W' string rgbw_s = self.fibaro_device.properties.color if rgbw_s == '0,0,0,0' and\ From 9b3373a15bc77aea6de006c36fb5c26441ac6cf6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 30 Nov 2018 17:53:14 +0100 Subject: [PATCH 35/47] Bumped version to 0.83.2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6b69609be22..1a1d45396f0 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '1' +PATCH_VERSION = '2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 1d717b768d4ce59ee03b904f093460626b50c3d4 Mon Sep 17 00:00:00 2001 From: Andrew Hayworth Date: Sun, 2 Dec 2018 04:14:46 -0600 Subject: [PATCH 36/47] bugfix: ensure the `google_assistant` component respects `allow_unlock` (#18874) The `Config` object specific to the `google_assistant` component had a default value for `allow_unlock`. We were not overriding this default when constructing the Config object during `google_assistant` component setup, whereas we do when setting up the `cloud` component. To fix, we thread the `allow_unlock` parameter down through http setup, and ensure that it's set correctly. Moreover, we also change the ordering of the `Config` parameters, and remove the default. Future refactoring should not miss it, as it is now a required parameter. --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/google_assistant/helpers.py | 4 ++-- homeassistant/components/google_assistant/http.py | 8 ++++++-- tests/components/google_assistant/test_smart_home.py | 2 ++ tests/components/google_assistant/test_trait.py | 1 + 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 4f4b0c582fc..ba5621b1f8d 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -186,9 +186,9 @@ class Cloud: self._gactions_config = ga_h.Config( should_expose=should_expose, + allow_unlock=self.prefs.google_allow_unlock, agent_user_id=self.claims['cognito:username'], entity_config=conf.get(CONF_ENTITY_CONFIG), - allow_unlock=self.prefs.google_allow_unlock, ) return self._gactions_config diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index e71756d9fee..f20a4106a16 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -16,8 +16,8 @@ class SmartHomeError(Exception): class Config: """Hold the configuration for Google Assistant.""" - def __init__(self, should_expose, agent_user_id, entity_config=None, - allow_unlock=False): + def __init__(self, should_expose, allow_unlock, agent_user_id, + entity_config=None): """Initialize the configuration.""" self.should_expose = should_expose self.agent_user_id = agent_user_id diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index f29e8bbae12..d688491fe89 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -15,6 +15,7 @@ from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from .const import ( GOOGLE_ASSISTANT_API_ENDPOINT, + CONF_ALLOW_UNLOCK, CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, CONF_ENTITY_CONFIG, @@ -32,6 +33,7 @@ def async_register_http(hass, cfg): expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT) exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS) entity_config = cfg.get(CONF_ENTITY_CONFIG) or {} + allow_unlock = cfg.get(CONF_ALLOW_UNLOCK, False) def is_exposed(entity) -> bool: """Determine if an entity should be exposed to Google Assistant.""" @@ -57,7 +59,7 @@ def async_register_http(hass, cfg): return is_default_exposed or explicit_expose hass.http.register_view( - GoogleAssistantView(is_exposed, entity_config)) + GoogleAssistantView(is_exposed, entity_config, allow_unlock)) class GoogleAssistantView(HomeAssistantView): @@ -67,15 +69,17 @@ class GoogleAssistantView(HomeAssistantView): name = 'api:google_assistant' requires_auth = True - def __init__(self, is_exposed, entity_config): + def __init__(self, is_exposed, entity_config, allow_unlock): """Initialize the Google Assistant request handler.""" self.is_exposed = is_exposed self.entity_config = entity_config + self.allow_unlock = allow_unlock async def post(self, request: Request) -> Response: """Handle Google Assistant requests.""" message = await request.json() # type: dict config = Config(self.is_exposed, + self.allow_unlock, request['hass_user'].id, self.entity_config) result = await async_handle_message( diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 66e7747e06a..36971224f92 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -11,6 +11,7 @@ from homeassistant.components.light.demo import DemoLight BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, + allow_unlock=False, agent_user_id='test-agent', ) REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' @@ -35,6 +36,7 @@ async def test_sync_message(hass): config = helpers.Config( should_expose=lambda state: state.entity_id != 'light.not_expose', + allow_unlock=False, agent_user_id='test-agent', entity_config={ 'light.demo_light': { diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 42af1230eed..616c43464a6 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -25,6 +25,7 @@ from tests.common import async_mock_service BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, + allow_unlock=False, agent_user_id='test-agent', ) From 6de0ed3f0a016e30c65e8404c21a7762ebc0a9f3 Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 11:24:32 +0100 Subject: [PATCH 37/47] Fix stability issues with multiple units --- homeassistant/components/device_tracker/googlehome.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/googlehome.py b/homeassistant/components/device_tracker/googlehome.py index 575d9688493..e700301d579 100644 --- a/homeassistant/components/device_tracker/googlehome.py +++ b/homeassistant/components/device_tracker/googlehome.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['ghlocalapi==0.1.0'] +REQUIREMENTS = ['ghlocalapi==0.3.4'] _LOGGER = logging.getLogger(__name__) @@ -77,8 +77,8 @@ class GoogleHomeDeviceScanner(DeviceScanner): async def async_update_info(self): """Ensure the information from Google Home is up to date.""" _LOGGER.debug('Checking Devices...') - await self.scanner.scan_for_devices() await self.scanner.get_scan_result() + await self.scanner.scan_for_devices() ghname = self.deviceinfo.device_info['name'] devices = {} for device in self.scanner.devices: diff --git a/requirements_all.txt b/requirements_all.txt index e4020ca41e7..8f4f24f45ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ geojson_client==0.3 georss_client==0.4 # homeassistant.components.device_tracker.googlehome -ghlocalapi==0.1.0 +ghlocalapi==0.3.4 # homeassistant.components.sensor.gitter gitterpy==0.1.7 From 79a9c1af9ea1304ae987833220b79c6bee214da0 Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 16:28:22 +0100 Subject: [PATCH 38/47] bump ghlocalapi to use clear_scan_result --- homeassistant/components/device_tracker/googlehome.py | 5 +++-- requirements_all.txt | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/googlehome.py b/homeassistant/components/device_tracker/googlehome.py index e700301d579..dabb92a0751 100644 --- a/homeassistant/components/device_tracker/googlehome.py +++ b/homeassistant/components/device_tracker/googlehome.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['ghlocalapi==0.3.4'] +REQUIREMENTS = ['ghlocalapi==0.3.5'] _LOGGER = logging.getLogger(__name__) @@ -77,8 +77,8 @@ class GoogleHomeDeviceScanner(DeviceScanner): async def async_update_info(self): """Ensure the information from Google Home is up to date.""" _LOGGER.debug('Checking Devices...') - await self.scanner.get_scan_result() await self.scanner.scan_for_devices() + await self.scanner.get_scan_result() ghname = self.deviceinfo.device_info['name'] devices = {} for device in self.scanner.devices: @@ -89,4 +89,5 @@ class GoogleHomeDeviceScanner(DeviceScanner): devices[uuid]['btle_mac_address'] = device['mac_address'] devices[uuid]['ghname'] = ghname devices[uuid]['source_type'] = 'bluetooth' + await self.scanner.clear_scan_result() self.last_results = devices diff --git a/requirements_all.txt b/requirements_all.txt index 8f4f24f45ed..c016d4596ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ geojson_client==0.3 georss_client==0.4 # homeassistant.components.device_tracker.googlehome -ghlocalapi==0.3.4 +ghlocalapi==0.3.5 # homeassistant.components.sensor.gitter gitterpy==0.1.7 From f8218b5e01bc67ad1e8e01f032d7a9f7aba27ac3 Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 13:02:15 +0100 Subject: [PATCH 39/47] Fix IndexError for home stats --- homeassistant/components/sensor/tautulli.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/tautulli.py b/homeassistant/components/sensor/tautulli.py index 7b0d8e491d2..419ef6a11a1 100644 --- a/homeassistant/components/sensor/tautulli.py +++ b/homeassistant/components/sensor/tautulli.py @@ -19,7 +19,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pytautulli==0.4.0'] +REQUIREMENTS = ['pytautulli==0.4.1'] _LOGGER = logging.getLogger(__name__) @@ -90,9 +90,9 @@ class TautulliSensor(Entity): await self.tautulli.async_update() self.home = self.tautulli.api.home_data self.sessions = self.tautulli.api.session_data - self._attributes['Top Movie'] = self.home[0]['rows'][0]['title'] - self._attributes['Top TV Show'] = self.home[3]['rows'][0]['title'] - self._attributes['Top User'] = self.home[7]['rows'][0]['user'] + self._attributes['Top Movie'] = self.home['movie'] + self._attributes['Top TV Show'] = self.home['tv'] + self._attributes['Top User'] = self.home['user'] for key in self.sessions: if 'sessions' not in key: self._attributes[key] = self.sessions[key] From 475be636d6d1fda3349dd444cb085f0000ea5b1a Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 13:07:32 +0100 Subject: [PATCH 40/47] Fix requirements_all --- requirements_all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_all.txt b/requirements_all.txt index c016d4596ad..50a81aa6c2c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1154,7 +1154,7 @@ pystride==0.1.7 pysyncthru==0.3.1 # homeassistant.components.sensor.tautulli -pytautulli==0.4.0 +pytautulli==0.4.1 # homeassistant.components.media_player.liveboxplaytv pyteleloisirs==3.4 From 82d89edb4f2cd82b785c955e03ccdd1d95c6073b Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 21:32:31 +0100 Subject: [PATCH 41/47] Use dict.get('key') instead of dict['key'] --- homeassistant/components/sensor/tautulli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/tautulli.py b/homeassistant/components/sensor/tautulli.py index 419ef6a11a1..29c11c934f9 100644 --- a/homeassistant/components/sensor/tautulli.py +++ b/homeassistant/components/sensor/tautulli.py @@ -90,9 +90,9 @@ class TautulliSensor(Entity): await self.tautulli.async_update() self.home = self.tautulli.api.home_data self.sessions = self.tautulli.api.session_data - self._attributes['Top Movie'] = self.home['movie'] - self._attributes['Top TV Show'] = self.home['tv'] - self._attributes['Top User'] = self.home['user'] + self._attributes['Top Movie'] = self.home.get('movie') + self._attributes['Top TV Show'] = self.home,get('tv') + self._attributes['Top User'] = self.home.get('user') for key in self.sessions: if 'sessions' not in key: self._attributes[key] = self.sessions[key] From ee1c29b392ac0734c4fd0d1ee15fa44547612893 Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 21:34:31 +0100 Subject: [PATCH 42/47] corrects , -> . typo --- homeassistant/components/sensor/tautulli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/tautulli.py b/homeassistant/components/sensor/tautulli.py index 29c11c934f9..f47f0e5c382 100644 --- a/homeassistant/components/sensor/tautulli.py +++ b/homeassistant/components/sensor/tautulli.py @@ -91,7 +91,7 @@ class TautulliSensor(Entity): self.home = self.tautulli.api.home_data self.sessions = self.tautulli.api.session_data self._attributes['Top Movie'] = self.home.get('movie') - self._attributes['Top TV Show'] = self.home,get('tv') + self._attributes['Top TV Show'] = self.home.get('tv') self._attributes['Top User'] = self.home.get('user') for key in self.sessions: if 'sessions' not in key: From 3575c34f77aecaaacace741d66c33ba8201dd4a0 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Mon, 3 Dec 2018 05:34:22 -0500 Subject: [PATCH 43/47] Use capability of sensor if present to fix multisensor Wink devices (#18907) --- homeassistant/components/wink/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index a94f8c3bdf2..c4cefa2c2d1 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -690,6 +690,10 @@ class WinkDevice(Entity): @property def unique_id(self): """Return the unique id of the Wink device.""" + if hasattr(self.wink, 'capability') and \ + self.wink.capability() is not None: + return "{}_{}".format(self.wink.object_id(), + self.wink.capability()) return self.wink.object_id() @property From 35690d5b29b1f54ac7cadf3ee9777de15ad23061 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Dec 2018 11:34:01 +0100 Subject: [PATCH 44/47] Add users added via credentials to admin group too (#18922) * Add users added via credentials to admin group too * Update test_init.py --- homeassistant/auth/__init__.py | 1 + tests/auth/test_init.py | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 7d8ef13d2bb..49f01211e5a 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -190,6 +190,7 @@ class AuthManager: credentials=credentials, name=info.name, is_active=info.is_active, + group_ids=[GROUP_ID_ADMIN], ) self.hass.bus.async_fire(EVENT_USER_ADDED, { diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 4357ba1b1de..e950230f10a 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -870,3 +870,28 @@ async def test_async_remove_user(hass): await hass.async_block_till_done() assert len(events) == 1 assert events[0].data['user_id'] == user.id + + +async def test_new_users_admin(mock_hass): + """Test newly created users are admin.""" + manager = await auth.auth_manager_from_config(mock_hass, [{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + 'name': 'Test Name' + }] + }], []) + ensure_auth_manager_loaded(manager) + + user = await manager.async_create_user('Hello') + assert user.is_admin + + user_cred = await manager.async_get_or_create_user(auth_models.Credentials( + id='mock-id', + auth_provider_type='insecure_example', + auth_provider_id=None, + data={'username': 'test-user'}, + is_new=True, + )) + assert user_cred.is_admin From f6a79059e5a240540f8185cd6c419135f8e5c90c Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Tue, 27 Nov 2018 14:20:25 +0100 Subject: [PATCH 45/47] fix aioasuswrt sometimes return empty lists (#18742) * aioasuswrt sometimes return empty lists * Bumping aioasuswrt to 1.1.12 --- homeassistant/components/asuswrt.py | 2 +- homeassistant/components/sensor/asuswrt.py | 8 ++++---- requirements_all.txt | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/asuswrt.py b/homeassistant/components/asuswrt.py index c653c1d03fd..d72c8d77a2b 100644 --- a/homeassistant/components/asuswrt.py +++ b/homeassistant/components/asuswrt.py @@ -14,7 +14,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform -REQUIREMENTS = ['aioasuswrt==1.1.11'] +REQUIREMENTS = ['aioasuswrt==1.1.12'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/asuswrt.py b/homeassistant/components/sensor/asuswrt.py index 4ca088fb1e2..876f0dfd559 100644 --- a/homeassistant/components/sensor/asuswrt.py +++ b/homeassistant/components/sensor/asuswrt.py @@ -68,7 +68,7 @@ class AsuswrtRXSensor(AsuswrtSensor): async def async_update(self): """Fetch new state data for the sensor.""" await super().async_update() - if self._speed is not None: + if self._speed: self._state = round(self._speed[0] / 125000, 2) @@ -86,7 +86,7 @@ class AsuswrtTXSensor(AsuswrtSensor): async def async_update(self): """Fetch new state data for the sensor.""" await super().async_update() - if self._speed is not None: + if self._speed: self._state = round(self._speed[1] / 125000, 2) @@ -104,7 +104,7 @@ class AsuswrtTotalRXSensor(AsuswrtSensor): async def async_update(self): """Fetch new state data for the sensor.""" await super().async_update() - if self._rates is not None: + if self._rates: self._state = round(self._rates[0] / 1000000000, 1) @@ -122,5 +122,5 @@ class AsuswrtTotalTXSensor(AsuswrtSensor): async def async_update(self): """Fetch new state data for the sensor.""" await super().async_update() - if self._rates is not None: + if self._rates: self._state = round(self._rates[1] / 1000000000, 1) diff --git a/requirements_all.txt b/requirements_all.txt index 50a81aa6c2c..e939f1e4646 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -86,7 +86,7 @@ abodepy==0.14.0 afsapi==0.0.4 # homeassistant.components.asuswrt -aioasuswrt==1.1.11 +aioasuswrt==1.1.12 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 From 106cb63922955ef8af0ca64fbf8133e76a2e1483 Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Mon, 3 Dec 2018 11:13:06 +0100 Subject: [PATCH 46/47] bump aioasuswrt version (#18955) --- homeassistant/components/asuswrt.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/asuswrt.py b/homeassistant/components/asuswrt.py index d72c8d77a2b..719e857c751 100644 --- a/homeassistant/components/asuswrt.py +++ b/homeassistant/components/asuswrt.py @@ -14,7 +14,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform -REQUIREMENTS = ['aioasuswrt==1.1.12'] +REQUIREMENTS = ['aioasuswrt==1.1.13'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index e939f1e4646..169c54aa713 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -86,7 +86,7 @@ abodepy==0.14.0 afsapi==0.0.4 # homeassistant.components.asuswrt -aioasuswrt==1.1.12 +aioasuswrt==1.1.13 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 From 4ef1bf21570f15fca37262ce76deb08d61627204 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Dec 2018 11:42:49 +0100 Subject: [PATCH 47/47] Bumped version to 0.83.3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1a1d45396f0..63d4e9f00f5 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '2' +PATCH_VERSION = '3' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3)