Mobile App: Register devices into the registry (#21856)

* Register devices into the registry

* Switch to device ID instead of webhook ID

* Rearchitect mobile_app to support config entries

* Kill DATA_REGISTRATIONS by migrating registrations into config entries

* Fix tests

* Improve how we get the config_entry_id

* Remove single_instance_allowed

* Simplify setup_registration

* Move webhook registering functions into __init__.py since they are only ever used once

* Kill get_registration websocket command

* Support description_placeholders in async_abort

* Add link to mobile_app implementing apps in abort dialog

* Store config entry and device registry entry in hass.data instead of looking it up

* Add testing to ensure that the config entry is created at registration

* Fix busted async_abort test

* Remove unnecessary check for entry is None
This commit is contained in:
Robbie Trencheny 2019-03-14 12:57:50 -07:00 committed by GitHub
parent 62f12d242a
commit 3769f5893a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 254 additions and 212 deletions

View file

@ -0,0 +1,14 @@
{
"config": {
"title": "Mobile App",
"step": {
"confirm": {
"title": "Mobile App",
"description": "Do you want to set up the Mobile App component?"
}
},
"abort": {
"install_app": "Open the mobile app to set up the integration with Home Assistant. See [the docs]({apps_url}) for a list of compatible apps."
}
}
}

View file

@ -1,11 +1,18 @@
"""Integrates Native Apps to Home Assistant.""" """Integrates Native Apps to Home Assistant."""
from homeassistant import config_entries
from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.components.webhook import async_register as webhook_register
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from .const import (DATA_DELETED_IDS, DATA_REGISTRATIONS, DATA_STORE, DOMAIN, from .const import (ATTR_APP_COMPONENT, ATTR_DEVICE_ID, ATTR_DEVICE_NAME,
STORAGE_KEY, STORAGE_VERSION) ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION,
DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES,
DATA_STORE, DOMAIN, STORAGE_KEY, STORAGE_VERSION)
from .http_api import register_http_handlers from .http_api import RegistrationsView
from .webhook import register_deleted_webhooks, setup_registration from .webhook import handle_webhook
from .websocket_api import register_websocket_handlers from .websocket_api import register_websocket_handlers
DEPENDENCIES = ['device_tracker', 'http', 'webhook'] DEPENDENCIES = ['device_tracker', 'http', 'webhook']
@ -15,24 +22,88 @@ REQUIREMENTS = ['PyNaCl==1.3.0']
async def async_setup(hass: HomeAssistantType, config: ConfigType): async def async_setup(hass: HomeAssistantType, config: ConfigType):
"""Set up the mobile app component.""" """Set up the mobile app component."""
hass.data[DOMAIN] = {
DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], DATA_DEVICES: {},
}
store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
app_config = await store.async_load() app_config = await store.async_load()
if app_config is None: if app_config is None:
app_config = {DATA_DELETED_IDS: [], DATA_REGISTRATIONS: {}} app_config = {
DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], DATA_DEVICES: {},
}
if hass.data.get(DOMAIN) is None: hass.data[DOMAIN] = app_config
hass.data[DOMAIN] = {DATA_DELETED_IDS: [], DATA_REGISTRATIONS: {}}
hass.data[DOMAIN][DATA_DELETED_IDS] = app_config.get(DATA_DELETED_IDS, [])
hass.data[DOMAIN][DATA_REGISTRATIONS] = app_config.get(DATA_REGISTRATIONS,
{})
hass.data[DOMAIN][DATA_STORE] = store hass.data[DOMAIN][DATA_STORE] = store
for registration in app_config[DATA_REGISTRATIONS].values(): hass.http.register_view(RegistrationsView())
setup_registration(hass, store, registration)
register_http_handlers(hass, store)
register_websocket_handlers(hass) register_websocket_handlers(hass)
register_deleted_webhooks(hass, store)
for deleted_id in hass.data[DOMAIN][DATA_DELETED_IDS]:
try:
webhook_register(hass, DOMAIN, "Deleted Webhook", deleted_id,
handle_webhook)
except ValueError:
pass
return True return True
async def async_setup_entry(hass, entry):
"""Set up a mobile_app entry."""
registration = entry.data
webhook_id = registration[CONF_WEBHOOK_ID]
hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] = entry
device_registry = await dr.async_get_registry(hass)
identifiers = {
(ATTR_DEVICE_ID, registration[ATTR_DEVICE_ID]),
(CONF_WEBHOOK_ID, registration[CONF_WEBHOOK_ID])
}
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers=identifiers,
manufacturer=registration[ATTR_MANUFACTURER],
model=registration[ATTR_MODEL],
name=registration[ATTR_DEVICE_NAME],
sw_version=registration[ATTR_OS_VERSION]
)
hass.data[DOMAIN][DATA_DEVICES][webhook_id] = device
registration_name = 'Mobile App: {}'.format(registration[ATTR_DEVICE_NAME])
webhook_register(hass, DOMAIN, registration_name, webhook_id,
handle_webhook)
if ATTR_APP_COMPONENT in registration:
load_platform(hass, registration[ATTR_APP_COMPONENT], DOMAIN, {},
{DOMAIN: {}})
return True
@config_entries.HANDLERS.register(DOMAIN)
class MobileAppFlowHandler(config_entries.ConfigFlow):
"""Handle a Mobile App config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
placeholders = {
'apps_url':
'https://www.home-assistant.io/components/mobile_app/#apps'
}
return self.async_abort(reason='install_app',
description_placeholders=placeholders)
async def async_step_registration(self, user_input=None):
"""Handle a flow initialized during registration."""
return self.async_create_entry(title=user_input[ATTR_DEVICE_NAME],
data=user_input)

View file

@ -17,8 +17,9 @@ CONF_CLOUDHOOK_URL = 'cloudhook_url'
CONF_SECRET = 'secret' CONF_SECRET = 'secret'
CONF_USER_ID = 'user_id' CONF_USER_ID = 'user_id'
DATA_CONFIG_ENTRIES = 'config_entries'
DATA_DELETED_IDS = 'deleted_ids' DATA_DELETED_IDS = 'deleted_ids'
DATA_REGISTRATIONS = 'registrations' DATA_DEVICES = 'devices'
DATA_STORE = 'store' DATA_STORE = 'store'
ATTR_APP_COMPONENT = 'app_component' ATTR_APP_COMPONENT = 'app_component'
@ -26,6 +27,7 @@ ATTR_APP_DATA = 'app_data'
ATTR_APP_ID = 'app_id' ATTR_APP_ID = 'app_id'
ATTR_APP_NAME = 'app_name' ATTR_APP_NAME = 'app_name'
ATTR_APP_VERSION = 'app_version' ATTR_APP_VERSION = 'app_version'
ATTR_CONFIG_ENTRY_ID = 'entry_id'
ATTR_DEVICE_ID = 'device_id' ATTR_DEVICE_ID = 'device_id'
ATTR_DEVICE_NAME = 'device_name' ATTR_DEVICE_NAME = 'device_name'
ATTR_MANUFACTURER = 'manufacturer' ATTR_MANUFACTURER = 'manufacturer'
@ -52,7 +54,6 @@ ATTR_WEBHOOK_TYPE = 'type'
ERR_ENCRYPTION_REQUIRED = 'encryption_required' ERR_ENCRYPTION_REQUIRED = 'encryption_required'
ERR_INVALID_COMPONENT = 'invalid_component' ERR_INVALID_COMPONENT = 'invalid_component'
ERR_SAVE_FAILURE = 'save_failure'
WEBHOOK_TYPE_CALL_SERVICE = 'call_service' WEBHOOK_TYPE_CALL_SERVICE = 'call_service'
WEBHOOK_TYPE_FIRE_EVENT = 'fire_event' WEBHOOK_TYPE_FIRE_EVENT = 'fire_event'

View file

@ -11,8 +11,7 @@ from homeassistant.helpers.typing import HomeAssistantType
from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME, from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME,
ATTR_APP_VERSION, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, ATTR_APP_VERSION, ATTR_DEVICE_NAME, ATTR_MANUFACTURER,
ATTR_MODEL, ATTR_OS_VERSION, ATTR_SUPPORTS_ENCRYPTION, ATTR_MODEL, ATTR_OS_VERSION, ATTR_SUPPORTS_ENCRYPTION,
CONF_SECRET, CONF_USER_ID, DATA_DELETED_IDS, CONF_SECRET, CONF_USER_ID, DATA_DELETED_IDS, DOMAIN)
DATA_REGISTRATIONS, DOMAIN)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -125,7 +124,6 @@ def savable_state(hass: HomeAssistantType) -> Dict:
"""Return a clean object containing things that should be saved.""" """Return a clean object containing things that should be saved."""
return { return {
DATA_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS], DATA_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS],
DATA_REGISTRATIONS: hass.data[DOMAIN][DATA_REGISTRATIONS]
} }

View file

@ -8,29 +8,16 @@ from homeassistant.auth.util import generate_secret
from homeassistant.components.cloud import async_create_cloudhook from homeassistant.components.cloud import async_create_cloudhook
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.const import (HTTP_CREATED, HTTP_INTERNAL_SERVER_ERROR, from homeassistant.const import (HTTP_CREATED, CONF_WEBHOOK_ID)
CONF_WEBHOOK_ID)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.loader import get_component from homeassistant.loader import get_component
from .const import (ATTR_APP_COMPONENT, ATTR_DEVICE_ID, from .const import (ATTR_APP_COMPONENT, ATTR_DEVICE_ID,
ATTR_SUPPORTS_ENCRYPTION, CONF_CLOUDHOOK_URL, CONF_SECRET, ATTR_SUPPORTS_ENCRYPTION, CONF_CLOUDHOOK_URL, CONF_SECRET,
CONF_USER_ID, DATA_REGISTRATIONS, DOMAIN, CONF_USER_ID, DOMAIN, ERR_INVALID_COMPONENT,
ERR_INVALID_COMPONENT, ERR_SAVE_FAILURE,
REGISTRATION_SCHEMA) REGISTRATION_SCHEMA)
from .helpers import error_response, supports_encryption, savable_state from .helpers import error_response, supports_encryption
from .webhook import setup_registration
def register_http_handlers(hass: HomeAssistantType, store: Store) -> bool:
"""Register the HTTP handlers/views."""
hass.http.register_view(RegistrationsView(store))
return True
class RegistrationsView(HomeAssistantView): class RegistrationsView(HomeAssistantView):
@ -39,10 +26,6 @@ class RegistrationsView(HomeAssistantView):
url = '/api/mobile_app/registrations' url = '/api/mobile_app/registrations'
name = 'api:mobile_app:register' name = 'api:mobile_app:register'
def __init__(self, store: Store) -> None:
"""Initialize the view."""
self._store = store
@RequestDataValidator(REGISTRATION_SCHEMA) @RequestDataValidator(REGISTRATION_SCHEMA)
async def post(self, request: Request, data: Dict) -> Response: async def post(self, request: Request, data: Dict) -> Response:
"""Handle the POST request for registration.""" """Handle the POST request for registration."""
@ -79,16 +62,10 @@ class RegistrationsView(HomeAssistantView):
data[CONF_USER_ID] = request['hass_user'].id data[CONF_USER_ID] = request['hass_user'].id
hass.data[DOMAIN][DATA_REGISTRATIONS][webhook_id] = data ctx = {'source': 'registration'}
await hass.async_create_task(
try: hass.config_entries.flow.async_init(DOMAIN, context=ctx,
await self._store.async_save(savable_state(hass)) data=data))
except HomeAssistantError:
return error_response(ERR_SAVE_FAILURE,
"Error saving registration",
status=HTTP_INTERNAL_SERVER_ERROR)
setup_registration(hass, self._store, data)
return self.json({ return self.json({
CONF_CLOUDHOOK_URL: data.get(CONF_CLOUDHOOK_URL), CONF_CLOUDHOOK_URL: data.get(CONF_CLOUDHOOK_URL),

View file

@ -0,0 +1,14 @@
{
"config": {
"title": "Mobile App",
"step": {
"confirm": {
"title": "Mobile App",
"description": "Do you want to set up the Mobile App component?"
}
},
"abort": {
"install_app": "Open the mobile app to set up the integration with Home Assistant. See [the docs]({apps_url}) for a list of compatible apps."
}
}
}

View file

@ -1,7 +1,5 @@
"""Webhook handlers for mobile_app.""" """Webhook handlers for mobile_app."""
from functools import partial
import logging import logging
from typing import Dict
from aiohttp.web import HTTPBadRequest, Response, Request from aiohttp.web import HTTPBadRequest, Response, Request
import voluptuous as vol import voluptuous as vol
@ -10,27 +8,24 @@ from homeassistant.components.device_tracker import (ATTR_ATTRIBUTES,
ATTR_DEV_ID, ATTR_DEV_ID,
DOMAIN as DT_DOMAIN, DOMAIN as DT_DOMAIN,
SERVICE_SEE as DT_SEE) SERVICE_SEE as DT_SEE)
from homeassistant.components.webhook import async_register as webhook_register
from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA, from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA,
CONF_WEBHOOK_ID, HTTP_BAD_REQUEST) CONF_WEBHOOK_ID, HTTP_BAD_REQUEST)
from homeassistant.core import EventOrigin from homeassistant.core import EventOrigin
from homeassistant.exceptions import (HomeAssistantError, ServiceNotFound, from homeassistant.exceptions import (ServiceNotFound, TemplateError)
TemplateError) from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.template import attach from homeassistant.helpers.template import attach
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.typing import HomeAssistantType
from .const import (ATTR_ALTITUDE, ATTR_APP_COMPONENT, ATTR_BATTERY, from .const import (ATTR_ALTITUDE, ATTR_BATTERY, ATTR_COURSE, ATTR_DEVICE_ID,
ATTR_COURSE, ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_DEVICE_NAME, ATTR_EVENT_DATA, ATTR_EVENT_TYPE,
ATTR_EVENT_DATA, ATTR_EVENT_TYPE, ATTR_GPS, ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_LOCATION_NAME,
ATTR_GPS_ACCURACY, ATTR_LOCATION_NAME, ATTR_SPEED, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, ATTR_SPEED,
ATTR_SUPPORTS_ENCRYPTION, ATTR_TEMPLATE, ATTR_SUPPORTS_ENCRYPTION, ATTR_TEMPLATE,
ATTR_TEMPLATE_VARIABLES, ATTR_VERTICAL_ACCURACY, ATTR_TEMPLATE_VARIABLES, ATTR_VERTICAL_ACCURACY,
ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED, ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED,
ATTR_WEBHOOK_ENCRYPTED_DATA, ATTR_WEBHOOK_TYPE, ATTR_WEBHOOK_ENCRYPTED_DATA, ATTR_WEBHOOK_TYPE,
CONF_SECRET, DATA_DELETED_IDS, DATA_REGISTRATIONS, DOMAIN, CONF_SECRET, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DOMAIN,
ERR_ENCRYPTION_REQUIRED, WEBHOOK_PAYLOAD_SCHEMA, ERR_ENCRYPTION_REQUIRED, WEBHOOK_PAYLOAD_SCHEMA,
WEBHOOK_SCHEMAS, WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_SCHEMAS, WEBHOOK_TYPE_CALL_SERVICE,
WEBHOOK_TYPE_FIRE_EVENT, WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_FIRE_EVENT, WEBHOOK_TYPE_RENDER_TEMPLATE,
@ -38,45 +33,24 @@ from .const import (ATTR_ALTITUDE, ATTR_APP_COMPONENT, ATTR_BATTERY,
WEBHOOK_TYPE_UPDATE_REGISTRATION) WEBHOOK_TYPE_UPDATE_REGISTRATION)
from .helpers import (_decrypt_payload, empty_okay_response, error_response, from .helpers import (_decrypt_payload, empty_okay_response, error_response,
registration_context, safe_registration, savable_state, registration_context, safe_registration,
webhook_response) webhook_response)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def register_deleted_webhooks(hass: HomeAssistantType, store: Store): async def handle_webhook(hass: HomeAssistantType, webhook_id: str,
"""Register previously deleted webhook IDs so we can return 410.""" request: Request) -> Response:
for deleted_id in hass.data[DOMAIN][DATA_DELETED_IDS]:
try:
webhook_register(hass, DOMAIN, "Deleted Webhook", deleted_id,
partial(handle_webhook, store))
except ValueError:
pass
def setup_registration(hass: HomeAssistantType, store: Store,
registration: Dict) -> None:
"""Register the webhook for a registration and loads the app component."""
registration_name = 'Mobile App: {}'.format(registration[ATTR_DEVICE_NAME])
webhook_id = registration[CONF_WEBHOOK_ID]
webhook_register(hass, DOMAIN, registration_name, webhook_id,
partial(handle_webhook, store))
if ATTR_APP_COMPONENT in registration:
load_platform(hass, registration[ATTR_APP_COMPONENT], DOMAIN, {},
{DOMAIN: {}})
async def handle_webhook(store: Store, hass: HomeAssistantType,
webhook_id: str, request: Request) -> Response:
"""Handle webhook callback.""" """Handle webhook callback."""
if webhook_id in hass.data[DOMAIN][DATA_DELETED_IDS]: if webhook_id in hass.data[DOMAIN][DATA_DELETED_IDS]:
return Response(status=410) return Response(status=410)
headers = {} headers = {}
registration = hass.data[DOMAIN][DATA_REGISTRATIONS][webhook_id] config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id]
registration = config_entry.data
try: try:
req_data = await request.json() req_data = await request.json()
@ -179,13 +153,22 @@ async def handle_webhook(store: Store, hass: HomeAssistantType,
if webhook_type == WEBHOOK_TYPE_UPDATE_REGISTRATION: if webhook_type == WEBHOOK_TYPE_UPDATE_REGISTRATION:
new_registration = {**registration, **data} new_registration = {**registration, **data}
hass.data[DOMAIN][DATA_REGISTRATIONS][webhook_id] = new_registration device_registry = await dr.async_get_registry(hass)
try: device_registry.async_get_or_create(
await store.async_save(savable_state(hass)) config_entry_id=config_entry.entry_id,
except HomeAssistantError as ex: identifiers={
_LOGGER.error("Error updating mobile_app registration: %s", ex) (ATTR_DEVICE_ID, registration[ATTR_DEVICE_ID]),
return empty_okay_response() (CONF_WEBHOOK_ID, registration[CONF_WEBHOOK_ID])
},
manufacturer=new_registration[ATTR_MANUFACTURER],
model=new_registration[ATTR_MODEL],
name=new_registration[ATTR_DEVICE_NAME],
sw_version=new_registration[ATTR_OS_VERSION]
)
hass.config_entries.async_update_entry(config_entry,
data=new_registration)
return webhook_response(safe_registration(new_registration), return webhook_response(safe_registration(new_registration),
registration=registration, headers=headers) registration=registration, headers=headers)

View file

@ -17,16 +17,14 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.typing import HomeAssistantType
from .const import (CONF_CLOUDHOOK_URL, CONF_USER_ID, DATA_DELETED_IDS, from .const import (CONF_CLOUDHOOK_URL, CONF_USER_ID, DATA_CONFIG_ENTRIES,
DATA_REGISTRATIONS, DATA_STORE, DOMAIN) DATA_DELETED_IDS, DATA_STORE, DOMAIN)
from .helpers import safe_registration, savable_state from .helpers import safe_registration, savable_state
def register_websocket_handlers(hass: HomeAssistantType) -> bool: def register_websocket_handlers(hass: HomeAssistantType) -> bool:
"""Register the websocket handlers.""" """Register the websocket handlers."""
async_register_command(hass, websocket_get_registration)
async_register_command(hass, websocket_get_user_registrations) async_register_command(hass, websocket_get_user_registrations)
async_register_command(hass, websocket_delete_registration) async_register_command(hass, websocket_delete_registration)
@ -34,39 +32,6 @@ def register_websocket_handlers(hass: HomeAssistantType) -> bool:
return True return True
@ws_require_user()
@async_response
@websocket_command({
vol.Required('type'): 'mobile_app/get_registration',
vol.Required(CONF_WEBHOOK_ID): cv.string,
})
async def websocket_get_registration(
hass: HomeAssistantType, connection: ActiveConnection,
msg: dict) -> None:
"""Return the registration for the given webhook_id."""
user = connection.user
webhook_id = msg.get(CONF_WEBHOOK_ID)
if webhook_id is None:
connection.send_error(msg['id'], ERR_INVALID_FORMAT,
"Webhook ID not provided")
return
registration = hass.data[DOMAIN][DATA_REGISTRATIONS].get(webhook_id)
if registration is None:
connection.send_error(msg['id'], ERR_NOT_FOUND,
"Webhook ID not found in storage")
return
if registration[CONF_USER_ID] != user.id and not user.is_admin:
return error_message(
msg['id'], ERR_UNAUTHORIZED, 'User is not registration owner')
connection.send_message(
result_message(msg['id'], safe_registration(registration)))
@ws_require_user() @ws_require_user()
@async_response @async_response
@websocket_command({ @websocket_command({
@ -87,7 +52,8 @@ async def websocket_get_user_registrations(
user_registrations = [] user_registrations = []
for registration in hass.data[DOMAIN][DATA_REGISTRATIONS].values(): for config_entry in hass.config_entries.async_entries(domain=DOMAIN):
registration = config_entry.data
if connection.user.is_admin or registration[CONF_USER_ID] is user_id: if connection.user.is_admin or registration[CONF_USER_ID] is user_id:
user_registrations.append(safe_registration(registration)) user_registrations.append(safe_registration(registration))
@ -113,7 +79,9 @@ async def websocket_delete_registration(hass: HomeAssistantType,
"Webhook ID not provided") "Webhook ID not provided")
return return
registration = hass.data[DOMAIN][DATA_REGISTRATIONS].get(webhook_id) config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id]
registration = config_entry.data
if registration is None: if registration is None:
connection.send_error(msg['id'], ERR_NOT_FOUND, connection.send_error(msg['id'], ERR_NOT_FOUND,
@ -124,7 +92,7 @@ async def websocket_delete_registration(hass: HomeAssistantType,
return error_message( return error_message(
msg['id'], ERR_UNAUTHORIZED, 'User is not registration owner') msg['id'], ERR_UNAUTHORIZED, 'User is not registration owner')
del hass.data[DOMAIN][DATA_REGISTRATIONS][webhook_id] await hass.config_entries.async_remove(config_entry.entry_id)
hass.data[DOMAIN][DATA_DELETED_IDS].append(webhook_id) hass.data[DOMAIN][DATA_DELETED_IDS].append(webhook_id)

View file

@ -161,6 +161,7 @@ FLOWS = [
'locative', 'locative',
'luftdaten', 'luftdaten',
'mailgun', 'mailgun',
'mobile_app',
'mqtt', 'mqtt',
'nest', 'nest',
'openuv', 'openuv',

View file

@ -170,11 +170,13 @@ class FlowHandler:
} }
@callback @callback
def async_abort(self, *, reason: str) -> Dict: def async_abort(self, *, reason: str,
description_placeholders: Optional[Dict] = None) -> Dict:
"""Abort the config flow.""" """Abort the config flow."""
return { return {
'type': RESULT_TYPE_ABORT, 'type': RESULT_TYPE_ABORT,
'flow_id': self.flow_id, 'flow_id': self.flow_id,
'handler': self.handler, 'handler': self.handler,
'reason': reason 'reason': reason,
'description_placeholders': description_placeholders,
} }

View file

@ -226,6 +226,7 @@ def test_abort(hass, client):
data = yield from resp.json() data = yield from resp.json()
data.pop('flow_id') data.pop('flow_id')
assert data == { assert data == {
'description_placeholders': None,
'handler': 'test', 'handler': 'test',
'reason': 'bla', 'reason': 'bla',
'type': 'abort' 'type': 'abort'

View file

@ -2,48 +2,59 @@
# pylint: disable=redefined-outer-name,unused-import # pylint: disable=redefined-outer-name,unused-import
import pytest import pytest
from tests.common import mock_device_registry
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.components.mobile_app.const import (DATA_DELETED_IDS, from homeassistant.components.mobile_app.const import (DATA_CONFIG_ENTRIES,
DATA_REGISTRATIONS, DATA_DELETED_IDS,
CONF_SECRET, DATA_DEVICES,
CONF_USER_ID, DOMAIN, DOMAIN,
STORAGE_KEY, STORAGE_KEY,
STORAGE_VERSION) STORAGE_VERSION)
from homeassistant.const import CONF_WEBHOOK_ID
from .const import REGISTER, REGISTER_CLEARTEXT
@pytest.fixture @pytest.fixture
def webhook_client(hass, aiohttp_client, hass_storage, hass_admin_user): def registry(hass):
"""Return a configured device registry."""
return mock_device_registry(hass)
@pytest.fixture
async def create_registrations(authed_api_client):
"""Return two new registrations."""
enc_reg = await authed_api_client.post(
'/api/mobile_app/registrations', json=REGISTER
)
assert enc_reg.status == 201
enc_reg_json = await enc_reg.json()
clear_reg = await authed_api_client.post(
'/api/mobile_app/registrations', json=REGISTER_CLEARTEXT
)
assert clear_reg.status == 201
clear_reg_json = await clear_reg.json()
return (enc_reg_json, clear_reg_json)
@pytest.fixture
async def webhook_client(hass, aiohttp_client, hass_storage, hass_admin_user):
"""mobile_app mock client.""" """mobile_app mock client."""
hass_storage[STORAGE_KEY] = { hass_storage[STORAGE_KEY] = {
'version': STORAGE_VERSION, 'version': STORAGE_VERSION,
'data': { 'data': {
DATA_REGISTRATIONS: { DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], DATA_DEVICES: {},
'mobile_app_test': {
CONF_SECRET: '58eb127991594dad934d1584bdee5f27',
'supports_encryption': True,
CONF_WEBHOOK_ID: 'mobile_app_test',
'device_name': 'Test Device',
CONF_USER_ID: hass_admin_user.id,
},
'mobile_app_test_cleartext': {
'supports_encryption': False,
CONF_WEBHOOK_ID: 'mobile_app_test_cleartext',
'device_name': 'Test Device (Cleartext)',
CONF_USER_ID: hass_admin_user.id,
}
},
DATA_DELETED_IDS: [],
} }
} }
assert hass.loop.run_until_complete(async_setup_component( await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
hass, DOMAIN, {
DOMAIN: {}
}))
return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) return await aiohttp_client(hass.http.app)
@pytest.fixture @pytest.fixture

View file

@ -2,14 +2,15 @@
# pylint: disable=redefined-outer-name,unused-import # pylint: disable=redefined-outer-name,unused-import
import pytest import pytest
from homeassistant.components.mobile_app.const import CONF_SECRET from homeassistant.components.mobile_app.const import CONF_SECRET, DOMAIN
from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.setup import async_setup_component
from .const import REGISTER, RENDER_TEMPLATE from .const import REGISTER, RENDER_TEMPLATE
from . import authed_api_client # noqa: F401 from . import authed_api_client # noqa: F401
async def test_registration(hass_client, authed_api_client): # noqa: F811 async def test_registration(hass, hass_client): # noqa: F811
"""Test that registrations happen.""" """Test that registrations happen."""
try: try:
# pylint: disable=unused-import # pylint: disable=unused-import
@ -21,7 +22,11 @@ async def test_registration(hass_client, authed_api_client): # noqa: F811
import json import json
resp = await authed_api_client.post( await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
api_client = await hass_client()
resp = await api_client.post(
'/api/mobile_app/registrations', json=REGISTER '/api/mobile_app/registrations', json=REGISTER
) )
@ -30,6 +35,20 @@ async def test_registration(hass_client, authed_api_client): # noqa: F811
assert CONF_WEBHOOK_ID in register_json assert CONF_WEBHOOK_ID in register_json
assert CONF_SECRET in register_json assert CONF_SECRET in register_json
entries = hass.config_entries.async_entries(DOMAIN)
assert entries[0].data['app_data'] == REGISTER['app_data']
assert entries[0].data['app_id'] == REGISTER['app_id']
assert entries[0].data['app_name'] == REGISTER['app_name']
assert entries[0].data['app_version'] == REGISTER['app_version']
assert entries[0].data['device_name'] == REGISTER['device_name']
assert entries[0].data['manufacturer'] == REGISTER['manufacturer']
assert entries[0].data['model'] == REGISTER['model']
assert entries[0].data['os_name'] == REGISTER['os_name']
assert entries[0].data['os_version'] == REGISTER['os_version']
assert entries[0].data['supports_encryption'] == \
REGISTER['supports_encryption']
keylen = SecretBox.KEY_SIZE keylen = SecretBox.KEY_SIZE
key = register_json[CONF_SECRET].encode("utf-8") key = register_json[CONF_SECRET].encode("utf-8")
key = key[:keylen] key = key[:keylen]
@ -46,9 +65,7 @@ async def test_registration(hass_client, authed_api_client): # noqa: F811
'encrypted_data': data, 'encrypted_data': data,
} }
webhook_client = await hass_client() resp = await api_client.post(
resp = await webhook_client.post(
'/api/webhook/{}'.format(register_json[CONF_WEBHOOK_ID]), '/api/webhook/{}'.format(register_json[CONF_WEBHOOK_ID]),
json=container json=container
) )

View file

@ -1,5 +1,6 @@
"""Webhook tests for mobile_app.""" """Webhook tests for mobile_app."""
# pylint: disable=redefined-outer-name,unused-import # pylint: disable=redefined-outer-name,unused-import
import logging
import pytest import pytest
from homeassistant.components.mobile_app.const import CONF_SECRET from homeassistant.components.mobile_app.const import CONF_SECRET
@ -8,16 +9,20 @@ from homeassistant.core import callback
from tests.common import async_mock_service from tests.common import async_mock_service
from . import authed_api_client, webhook_client # noqa: F401 from . import (authed_api_client, create_registrations, # noqa: F401
webhook_client) # noqa: F401
from .const import (CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT, from .const import (CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT,
RENDER_TEMPLATE, UPDATE) RENDER_TEMPLATE, UPDATE)
_LOGGER = logging.getLogger(__name__)
async def test_webhook_handle_render_template(webhook_client): # noqa: F811
async def test_webhook_handle_render_template(create_registrations, # noqa: F401, F811, E501
webhook_client): # noqa: F811
"""Test that we render templates properly.""" """Test that we render templates properly."""
resp = await webhook_client.post( resp = await webhook_client.post(
'/api/webhook/mobile_app_test_cleartext', '/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
json=RENDER_TEMPLATE json=RENDER_TEMPLATE
) )
@ -27,12 +32,13 @@ async def test_webhook_handle_render_template(webhook_client): # noqa: F811
assert json == {'one': 'Hello world'} assert json == {'one': 'Hello world'}
async def test_webhook_handle_call_services(hass, webhook_client): # noqa: E501 F811 async def test_webhook_handle_call_services(hass, create_registrations, # noqa: F401, F811, E501
webhook_client): # noqa: E501 F811
"""Test that we call services properly.""" """Test that we call services properly."""
calls = async_mock_service(hass, 'test', 'mobile_app') calls = async_mock_service(hass, 'test', 'mobile_app')
resp = await webhook_client.post( resp = await webhook_client.post(
'/api/webhook/mobile_app_test_cleartext', '/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
json=CALL_SERVICE json=CALL_SERVICE
) )
@ -41,7 +47,8 @@ async def test_webhook_handle_call_services(hass, webhook_client): # noqa: E501
assert len(calls) == 1 assert len(calls) == 1
async def test_webhook_handle_fire_event(hass, webhook_client): # noqa: F811 async def test_webhook_handle_fire_event(hass, create_registrations, # noqa: F401, F811, E501
webhook_client): # noqa: F811
"""Test that we can fire events.""" """Test that we can fire events."""
events = [] events = []
@ -53,7 +60,7 @@ async def test_webhook_handle_fire_event(hass, webhook_client): # noqa: F811
hass.bus.async_listen('test_event', store_event) hass.bus.async_listen('test_event', store_event)
resp = await webhook_client.post( resp = await webhook_client.post(
'/api/webhook/mobile_app_test_cleartext', '/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
json=FIRE_EVENT json=FIRE_EVENT
) )
@ -93,10 +100,12 @@ async def test_webhook_update_registration(webhook_client, hass_client): # noqa
assert CONF_SECRET not in update_json assert CONF_SECRET not in update_json
async def test_webhook_returns_error_incorrect_json(webhook_client, caplog): # noqa: E501 F811 async def test_webhook_returns_error_incorrect_json(webhook_client, # noqa: F401, F811, E501
create_registrations, # noqa: F401, F811, E501
caplog): # noqa: E501 F811
"""Test that an error is returned when JSON is invalid.""" """Test that an error is returned when JSON is invalid."""
resp = await webhook_client.post( resp = await webhook_client.post(
'/api/webhook/mobile_app_test_cleartext', '/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
data='not json' data='not json'
) )
@ -106,7 +115,8 @@ async def test_webhook_returns_error_incorrect_json(webhook_client, caplog): #
assert 'invalid JSON' in caplog.text assert 'invalid JSON' in caplog.text
async def test_webhook_handle_decryption(webhook_client): # noqa: F811 async def test_webhook_handle_decryption(webhook_client, # noqa: F811
create_registrations): # noqa: F401, F811, E501
"""Test that we can encrypt/decrypt properly.""" """Test that we can encrypt/decrypt properly."""
try: try:
# pylint: disable=unused-import # pylint: disable=unused-import
@ -119,7 +129,7 @@ async def test_webhook_handle_decryption(webhook_client): # noqa: F811
import json import json
keylen = SecretBox.KEY_SIZE keylen = SecretBox.KEY_SIZE
key = "58eb127991594dad934d1584bdee5f27".encode("utf-8") key = create_registrations[0]['secret'].encode("utf-8")
key = key[:keylen] key = key[:keylen]
key = key.ljust(keylen, b'\0') key = key.ljust(keylen, b'\0')
@ -135,7 +145,7 @@ async def test_webhook_handle_decryption(webhook_client): # noqa: F811
} }
resp = await webhook_client.post( resp = await webhook_client.post(
'/api/webhook/mobile_app_test', '/api/webhook/{}'.format(create_registrations[0]['webhook_id']),
json=container json=container
) )
@ -151,10 +161,11 @@ async def test_webhook_handle_decryption(webhook_client): # noqa: F811
assert json.loads(decrypted_data) == {'one': 'Hello world'} assert json.loads(decrypted_data) == {'one': 'Hello world'}
async def test_webhook_requires_encryption(webhook_client): # noqa: F811 async def test_webhook_requires_encryption(webhook_client, # noqa: F811
create_registrations): # noqa: F401, F811, E501
"""Test that encrypted registrations only accept encrypted data.""" """Test that encrypted registrations only accept encrypted data."""
resp = await webhook_client.post( resp = await webhook_client.post(
'/api/webhook/mobile_app_test', '/api/webhook/{}'.format(create_registrations[0]['webhook_id']),
json=RENDER_TEMPLATE json=RENDER_TEMPLATE
) )

View file

@ -9,33 +9,6 @@ from . import authed_api_client, setup_ws, webhook_client # noqa: F401
from .const import (CALL_SERVICE, REGISTER) from .const import (CALL_SERVICE, REGISTER)
async def test_webocket_get_registration(hass, setup_ws, authed_api_client, # noqa: E501 F811
hass_ws_client):
"""Test get_registration websocket command."""
register_resp = await authed_api_client.post(
'/api/mobile_app/registrations', json=REGISTER
)
assert register_resp.status == 201
register_json = await register_resp.json()
assert CONF_WEBHOOK_ID in register_json
assert CONF_SECRET in register_json
client = await hass_ws_client(hass)
await client.send_json({
'id': 5,
'type': 'mobile_app/get_registration',
CONF_WEBHOOK_ID: register_json[CONF_WEBHOOK_ID],
})
msg = await client.receive_json()
assert msg['id'] == 5
assert msg['type'] == TYPE_RESULT
assert msg['success']
assert msg['result']['app_id'] == 'io.homeassistant.mobile_app_test'
async def test_webocket_get_user_registrations(hass, aiohttp_client, async def test_webocket_get_user_registrations(hass, aiohttp_client,
hass_ws_client, hass_ws_client,
hass_read_only_access_token): hass_read_only_access_token):