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 Nonepull/22049/head
parent
62f12d242a
commit
3769f5893a
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,11 +1,18 @@
|
|||
"""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 .const import (DATA_DELETED_IDS, DATA_REGISTRATIONS, DATA_STORE, DOMAIN,
|
||||
STORAGE_KEY, STORAGE_VERSION)
|
||||
from .const import (ATTR_APP_COMPONENT, ATTR_DEVICE_ID, ATTR_DEVICE_NAME,
|
||||
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 .webhook import register_deleted_webhooks, setup_registration
|
||||
from .http_api import RegistrationsView
|
||||
from .webhook import handle_webhook
|
||||
from .websocket_api import register_websocket_handlers
|
||||
|
||||
DEPENDENCIES = ['device_tracker', 'http', 'webhook']
|
||||
|
@ -15,24 +22,88 @@ REQUIREMENTS = ['PyNaCl==1.3.0']
|
|||
|
||||
async def async_setup(hass: HomeAssistantType, config: ConfigType):
|
||||
"""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)
|
||||
app_config = await store.async_load()
|
||||
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] = {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] = app_config
|
||||
hass.data[DOMAIN][DATA_STORE] = store
|
||||
|
||||
for registration in app_config[DATA_REGISTRATIONS].values():
|
||||
setup_registration(hass, store, registration)
|
||||
|
||||
register_http_handlers(hass, store)
|
||||
hass.http.register_view(RegistrationsView())
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
|
|
|
@ -17,8 +17,9 @@ CONF_CLOUDHOOK_URL = 'cloudhook_url'
|
|||
CONF_SECRET = 'secret'
|
||||
CONF_USER_ID = 'user_id'
|
||||
|
||||
DATA_CONFIG_ENTRIES = 'config_entries'
|
||||
DATA_DELETED_IDS = 'deleted_ids'
|
||||
DATA_REGISTRATIONS = 'registrations'
|
||||
DATA_DEVICES = 'devices'
|
||||
DATA_STORE = 'store'
|
||||
|
||||
ATTR_APP_COMPONENT = 'app_component'
|
||||
|
@ -26,6 +27,7 @@ ATTR_APP_DATA = 'app_data'
|
|||
ATTR_APP_ID = 'app_id'
|
||||
ATTR_APP_NAME = 'app_name'
|
||||
ATTR_APP_VERSION = 'app_version'
|
||||
ATTR_CONFIG_ENTRY_ID = 'entry_id'
|
||||
ATTR_DEVICE_ID = 'device_id'
|
||||
ATTR_DEVICE_NAME = 'device_name'
|
||||
ATTR_MANUFACTURER = 'manufacturer'
|
||||
|
@ -52,7 +54,6 @@ ATTR_WEBHOOK_TYPE = 'type'
|
|||
|
||||
ERR_ENCRYPTION_REQUIRED = 'encryption_required'
|
||||
ERR_INVALID_COMPONENT = 'invalid_component'
|
||||
ERR_SAVE_FAILURE = 'save_failure'
|
||||
|
||||
WEBHOOK_TYPE_CALL_SERVICE = 'call_service'
|
||||
WEBHOOK_TYPE_FIRE_EVENT = 'fire_event'
|
||||
|
|
|
@ -11,8 +11,7 @@ from homeassistant.helpers.typing import HomeAssistantType
|
|||
from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME,
|
||||
ATTR_APP_VERSION, ATTR_DEVICE_NAME, ATTR_MANUFACTURER,
|
||||
ATTR_MODEL, ATTR_OS_VERSION, ATTR_SUPPORTS_ENCRYPTION,
|
||||
CONF_SECRET, CONF_USER_ID, DATA_DELETED_IDS,
|
||||
DATA_REGISTRATIONS, DOMAIN)
|
||||
CONF_SECRET, CONF_USER_ID, DATA_DELETED_IDS, DOMAIN)
|
||||
|
||||
_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 {
|
||||
DATA_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS],
|
||||
DATA_REGISTRATIONS: hass.data[DOMAIN][DATA_REGISTRATIONS]
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -8,29 +8,16 @@ from homeassistant.auth.util import generate_secret
|
|||
from homeassistant.components.cloud import async_create_cloudhook
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.const import (HTTP_CREATED, HTTP_INTERNAL_SERVER_ERROR,
|
||||
CONF_WEBHOOK_ID)
|
||||
from homeassistant.const import (HTTP_CREATED, 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 .const import (ATTR_APP_COMPONENT, ATTR_DEVICE_ID,
|
||||
ATTR_SUPPORTS_ENCRYPTION, CONF_CLOUDHOOK_URL, CONF_SECRET,
|
||||
CONF_USER_ID, DATA_REGISTRATIONS, DOMAIN,
|
||||
ERR_INVALID_COMPONENT, ERR_SAVE_FAILURE,
|
||||
CONF_USER_ID, DOMAIN, ERR_INVALID_COMPONENT,
|
||||
REGISTRATION_SCHEMA)
|
||||
|
||||
from .helpers import error_response, supports_encryption, savable_state
|
||||
|
||||
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
|
||||
from .helpers import error_response, supports_encryption
|
||||
|
||||
|
||||
class RegistrationsView(HomeAssistantView):
|
||||
|
@ -39,10 +26,6 @@ class RegistrationsView(HomeAssistantView):
|
|||
url = '/api/mobile_app/registrations'
|
||||
name = 'api:mobile_app:register'
|
||||
|
||||
def __init__(self, store: Store) -> None:
|
||||
"""Initialize the view."""
|
||||
self._store = store
|
||||
|
||||
@RequestDataValidator(REGISTRATION_SCHEMA)
|
||||
async def post(self, request: Request, data: Dict) -> Response:
|
||||
"""Handle the POST request for registration."""
|
||||
|
@ -79,16 +62,10 @@ class RegistrationsView(HomeAssistantView):
|
|||
|
||||
data[CONF_USER_ID] = request['hass_user'].id
|
||||
|
||||
hass.data[DOMAIN][DATA_REGISTRATIONS][webhook_id] = data
|
||||
|
||||
try:
|
||||
await self._store.async_save(savable_state(hass))
|
||||
except HomeAssistantError:
|
||||
return error_response(ERR_SAVE_FAILURE,
|
||||
"Error saving registration",
|
||||
status=HTTP_INTERNAL_SERVER_ERROR)
|
||||
|
||||
setup_registration(hass, self._store, data)
|
||||
ctx = {'source': 'registration'}
|
||||
await hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(DOMAIN, context=ctx,
|
||||
data=data))
|
||||
|
||||
return self.json({
|
||||
CONF_CLOUDHOOK_URL: data.get(CONF_CLOUDHOOK_URL),
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
"""Webhook handlers for mobile_app."""
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from aiohttp.web import HTTPBadRequest, Response, Request
|
||||
import voluptuous as vol
|
||||
|
@ -10,27 +8,24 @@ from homeassistant.components.device_tracker import (ATTR_ATTRIBUTES,
|
|||
ATTR_DEV_ID,
|
||||
DOMAIN as DT_DOMAIN,
|
||||
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,
|
||||
CONF_WEBHOOK_ID, HTTP_BAD_REQUEST)
|
||||
from homeassistant.core import EventOrigin
|
||||
from homeassistant.exceptions import (HomeAssistantError, ServiceNotFound,
|
||||
TemplateError)
|
||||
from homeassistant.exceptions import (ServiceNotFound, TemplateError)
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
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 .const import (ATTR_ALTITUDE, ATTR_APP_COMPONENT, ATTR_BATTERY,
|
||||
ATTR_COURSE, ATTR_DEVICE_ID, ATTR_DEVICE_NAME,
|
||||
ATTR_EVENT_DATA, ATTR_EVENT_TYPE, ATTR_GPS,
|
||||
ATTR_GPS_ACCURACY, ATTR_LOCATION_NAME, ATTR_SPEED,
|
||||
from .const import (ATTR_ALTITUDE, ATTR_BATTERY, ATTR_COURSE, ATTR_DEVICE_ID,
|
||||
ATTR_DEVICE_NAME, ATTR_EVENT_DATA, ATTR_EVENT_TYPE,
|
||||
ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_LOCATION_NAME,
|
||||
ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, ATTR_SPEED,
|
||||
ATTR_SUPPORTS_ENCRYPTION, ATTR_TEMPLATE,
|
||||
ATTR_TEMPLATE_VARIABLES, ATTR_VERTICAL_ACCURACY,
|
||||
ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED,
|
||||
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,
|
||||
WEBHOOK_SCHEMAS, WEBHOOK_TYPE_CALL_SERVICE,
|
||||
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)
|
||||
|
||||
from .helpers import (_decrypt_payload, empty_okay_response, error_response,
|
||||
registration_context, safe_registration, savable_state,
|
||||
registration_context, safe_registration,
|
||||
webhook_response)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_deleted_webhooks(hass: HomeAssistantType, store: Store):
|
||||
"""Register previously deleted webhook IDs so we can return 410."""
|
||||
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:
|
||||
async def handle_webhook(hass: HomeAssistantType, webhook_id: str,
|
||||
request: Request) -> Response:
|
||||
"""Handle webhook callback."""
|
||||
if webhook_id in hass.data[DOMAIN][DATA_DELETED_IDS]:
|
||||
return Response(status=410)
|
||||
|
||||
headers = {}
|
||||
|
||||
registration = hass.data[DOMAIN][DATA_REGISTRATIONS][webhook_id]
|
||||
config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id]
|
||||
|
||||
registration = config_entry.data
|
||||
|
||||
try:
|
||||
req_data = await request.json()
|
||||
|
@ -179,13 +153,22 @@ async def handle_webhook(store: Store, hass: HomeAssistantType,
|
|||
if webhook_type == WEBHOOK_TYPE_UPDATE_REGISTRATION:
|
||||
new_registration = {**registration, **data}
|
||||
|
||||
hass.data[DOMAIN][DATA_REGISTRATIONS][webhook_id] = new_registration
|
||||
device_registry = await dr.async_get_registry(hass)
|
||||
|
||||
try:
|
||||
await store.async_save(savable_state(hass))
|
||||
except HomeAssistantError as ex:
|
||||
_LOGGER.error("Error updating mobile_app registration: %s", ex)
|
||||
return empty_okay_response()
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
identifiers={
|
||||
(ATTR_DEVICE_ID, registration[ATTR_DEVICE_ID]),
|
||||
(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),
|
||||
registration=registration, headers=headers)
|
||||
|
|
|
@ -17,16 +17,14 @@ from homeassistant.exceptions import HomeAssistantError
|
|||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from .const import (CONF_CLOUDHOOK_URL, CONF_USER_ID, DATA_DELETED_IDS,
|
||||
DATA_REGISTRATIONS, DATA_STORE, DOMAIN)
|
||||
from .const import (CONF_CLOUDHOOK_URL, CONF_USER_ID, DATA_CONFIG_ENTRIES,
|
||||
DATA_DELETED_IDS, DATA_STORE, DOMAIN)
|
||||
|
||||
from .helpers import safe_registration, savable_state
|
||||
|
||||
|
||||
def register_websocket_handlers(hass: HomeAssistantType) -> bool:
|
||||
"""Register the websocket handlers."""
|
||||
async_register_command(hass, websocket_get_registration)
|
||||
|
||||
async_register_command(hass, websocket_get_user_registrations)
|
||||
|
||||
async_register_command(hass, websocket_delete_registration)
|
||||
|
@ -34,39 +32,6 @@ def register_websocket_handlers(hass: HomeAssistantType) -> bool:
|
|||
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()
|
||||
@async_response
|
||||
@websocket_command({
|
||||
|
@ -87,7 +52,8 @@ async def websocket_get_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:
|
||||
user_registrations.append(safe_registration(registration))
|
||||
|
||||
|
@ -113,7 +79,9 @@ async def websocket_delete_registration(hass: HomeAssistantType,
|
|||
"Webhook ID not provided")
|
||||
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:
|
||||
connection.send_error(msg['id'], ERR_NOT_FOUND,
|
||||
|
@ -124,7 +92,7 @@ async def websocket_delete_registration(hass: HomeAssistantType,
|
|||
return error_message(
|
||||
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)
|
||||
|
||||
|
|
|
@ -161,6 +161,7 @@ FLOWS = [
|
|||
'locative',
|
||||
'luftdaten',
|
||||
'mailgun',
|
||||
'mobile_app',
|
||||
'mqtt',
|
||||
'nest',
|
||||
'openuv',
|
||||
|
|
|
@ -170,11 +170,13 @@ class FlowHandler:
|
|||
}
|
||||
|
||||
@callback
|
||||
def async_abort(self, *, reason: str) -> Dict:
|
||||
def async_abort(self, *, reason: str,
|
||||
description_placeholders: Optional[Dict] = None) -> Dict:
|
||||
"""Abort the config flow."""
|
||||
return {
|
||||
'type': RESULT_TYPE_ABORT,
|
||||
'flow_id': self.flow_id,
|
||||
'handler': self.handler,
|
||||
'reason': reason
|
||||
'reason': reason,
|
||||
'description_placeholders': description_placeholders,
|
||||
}
|
||||
|
|
|
@ -226,6 +226,7 @@ def test_abort(hass, client):
|
|||
data = yield from resp.json()
|
||||
data.pop('flow_id')
|
||||
assert data == {
|
||||
'description_placeholders': None,
|
||||
'handler': 'test',
|
||||
'reason': 'bla',
|
||||
'type': 'abort'
|
||||
|
|
|
@ -2,48 +2,59 @@
|
|||
# pylint: disable=redefined-outer-name,unused-import
|
||||
import pytest
|
||||
|
||||
from tests.common import mock_device_registry
|
||||
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from homeassistant.components.mobile_app.const import (DATA_DELETED_IDS,
|
||||
DATA_REGISTRATIONS,
|
||||
CONF_SECRET,
|
||||
CONF_USER_ID, DOMAIN,
|
||||
from homeassistant.components.mobile_app.const import (DATA_CONFIG_ENTRIES,
|
||||
DATA_DELETED_IDS,
|
||||
DATA_DEVICES,
|
||||
DOMAIN,
|
||||
STORAGE_KEY,
|
||||
STORAGE_VERSION)
|
||||
from homeassistant.const import CONF_WEBHOOK_ID
|
||||
|
||||
from .const import REGISTER, REGISTER_CLEARTEXT
|
||||
|
||||
|
||||
@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."""
|
||||
hass_storage[STORAGE_KEY] = {
|
||||
'version': STORAGE_VERSION,
|
||||
'data': {
|
||||
DATA_REGISTRATIONS: {
|
||||
'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: [],
|
||||
DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], DATA_DEVICES: {},
|
||||
}
|
||||
}
|
||||
|
||||
assert hass.loop.run_until_complete(async_setup_component(
|
||||
hass, DOMAIN, {
|
||||
DOMAIN: {}
|
||||
}))
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
return hass.loop.run_until_complete(aiohttp_client(hass.http.app))
|
||||
return await aiohttp_client(hass.http.app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
@ -2,14 +2,15 @@
|
|||
# pylint: disable=redefined-outer-name,unused-import
|
||||
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.setup import async_setup_component
|
||||
|
||||
from .const import REGISTER, RENDER_TEMPLATE
|
||||
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."""
|
||||
try:
|
||||
# pylint: disable=unused-import
|
||||
|
@ -21,7 +22,11 @@ async def test_registration(hass_client, authed_api_client): # noqa: F811
|
|||
|
||||
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
|
||||
)
|
||||
|
||||
|
@ -30,6 +35,20 @@ async def test_registration(hass_client, authed_api_client): # noqa: F811
|
|||
assert CONF_WEBHOOK_ID 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
|
||||
key = register_json[CONF_SECRET].encode("utf-8")
|
||||
key = key[:keylen]
|
||||
|
@ -46,9 +65,7 @@ async def test_registration(hass_client, authed_api_client): # noqa: F811
|
|||
'encrypted_data': data,
|
||||
}
|
||||
|
||||
webhook_client = await hass_client()
|
||||
|
||||
resp = await webhook_client.post(
|
||||
resp = await api_client.post(
|
||||
'/api/webhook/{}'.format(register_json[CONF_WEBHOOK_ID]),
|
||||
json=container
|
||||
)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
"""Webhook tests for mobile_app."""
|
||||
# pylint: disable=redefined-outer-name,unused-import
|
||||
import logging
|
||||
import pytest
|
||||
|
||||
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 . 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,
|
||||
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."""
|
||||
resp = await webhook_client.post(
|
||||
'/api/webhook/mobile_app_test_cleartext',
|
||||
'/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
|
||||
json=RENDER_TEMPLATE
|
||||
)
|
||||
|
||||
|
@ -27,12 +32,13 @@ async def test_webhook_handle_render_template(webhook_client): # noqa: F811
|
|||
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."""
|
||||
calls = async_mock_service(hass, 'test', 'mobile_app')
|
||||
|
||||
resp = await webhook_client.post(
|
||||
'/api/webhook/mobile_app_test_cleartext',
|
||||
'/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
|
||||
json=CALL_SERVICE
|
||||
)
|
||||
|
||||
|
@ -41,7 +47,8 @@ async def test_webhook_handle_call_services(hass, webhook_client): # noqa: E501
|
|||
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."""
|
||||
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)
|
||||
|
||||
resp = await webhook_client.post(
|
||||
'/api/webhook/mobile_app_test_cleartext',
|
||||
'/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
resp = await webhook_client.post(
|
||||
'/api/webhook/mobile_app_test_cleartext',
|
||||
'/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
|
||||
data='not json'
|
||||
)
|
||||
|
||||
|
@ -106,7 +115,8 @@ async def test_webhook_returns_error_incorrect_json(webhook_client, caplog): #
|
|||
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."""
|
||||
try:
|
||||
# pylint: disable=unused-import
|
||||
|
@ -119,7 +129,7 @@ async def test_webhook_handle_decryption(webhook_client): # noqa: F811
|
|||
import json
|
||||
|
||||
keylen = SecretBox.KEY_SIZE
|
||||
key = "58eb127991594dad934d1584bdee5f27".encode("utf-8")
|
||||
key = create_registrations[0]['secret'].encode("utf-8")
|
||||
key = key[:keylen]
|
||||
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(
|
||||
'/api/webhook/mobile_app_test',
|
||||
'/api/webhook/{}'.format(create_registrations[0]['webhook_id']),
|
||||
json=container
|
||||
)
|
||||
|
||||
|
@ -151,10 +161,11 @@ async def test_webhook_handle_decryption(webhook_client): # noqa: F811
|
|||
assert json.loads(decrypted_data) == {'one': 'Hello world'}
|
||||
|
||||
|
||||
async def test_webhook_requires_encryption(webhook_client): # noqa: F811
|
||||
async def test_webhook_requires_encryption(webhook_client, # noqa: F811
|
||||
create_registrations): # noqa: F401, F811, E501
|
||||
"""Test that encrypted registrations only accept encrypted data."""
|
||||
resp = await webhook_client.post(
|
||||
'/api/webhook/mobile_app_test',
|
||||
'/api/webhook/{}'.format(create_registrations[0]['webhook_id']),
|
||||
json=RENDER_TEMPLATE
|
||||
)
|
||||
|
||||
|
|
|
@ -9,33 +9,6 @@ from . import authed_api_client, setup_ws, webhook_client # noqa: F401
|
|||
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,
|
||||
hass_ws_client,
|
||||
hass_read_only_access_token):
|
||||
|
|
Loading…
Reference in New Issue