1
mirror of https://github.com/home-assistant/core synced 2024-08-02 23:40:32 +02:00
ha-core/homeassistant/components/cloud/http_api.py
2022-01-26 09:34:48 +01:00

627 lines
20 KiB
Python

"""The HTTP api to control the cloud integration."""
import asyncio
from functools import wraps
from http import HTTPStatus
import logging
import aiohttp
import async_timeout
import attr
from hass_nabucasa import Cloud, auth, cloud_api, thingtalk
from hass_nabucasa.const import STATE_DISCONNECTED
from hass_nabucasa.voice import MAP_VOICE
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.alexa import (
entities as alexa_entities,
errors as alexa_errors,
)
from homeassistant.components.google_assistant import helpers as google_helpers
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.websocket_api import const as ws_const
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.location import async_detect_location_info
from .const import (
DOMAIN,
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_REPORT_STATE,
PREF_ENABLE_ALEXA,
PREF_ENABLE_GOOGLE,
PREF_GOOGLE_DEFAULT_EXPOSE,
PREF_GOOGLE_REPORT_STATE,
PREF_GOOGLE_SECURE_DEVICES_PIN,
PREF_TTS_DEFAULT_VOICE,
REQUEST_TIMEOUT,
)
_LOGGER = logging.getLogger(__name__)
_CLOUD_ERRORS = {
asyncio.TimeoutError: (
HTTPStatus.BAD_GATEWAY,
"Unable to reach the Home Assistant cloud.",
),
aiohttp.ClientError: (
HTTPStatus.INTERNAL_SERVER_ERROR,
"Error making internal request",
),
}
async def async_setup(hass):
"""Initialize the HTTP API."""
websocket_api.async_register_command(hass, websocket_cloud_status)
websocket_api.async_register_command(hass, websocket_subscription)
websocket_api.async_register_command(hass, websocket_update_prefs)
websocket_api.async_register_command(hass, websocket_hook_create)
websocket_api.async_register_command(hass, websocket_hook_delete)
websocket_api.async_register_command(hass, websocket_remote_connect)
websocket_api.async_register_command(hass, websocket_remote_disconnect)
websocket_api.async_register_command(hass, google_assistant_list)
websocket_api.async_register_command(hass, google_assistant_update)
websocket_api.async_register_command(hass, alexa_list)
websocket_api.async_register_command(hass, alexa_update)
websocket_api.async_register_command(hass, alexa_sync)
websocket_api.async_register_command(hass, thingtalk_convert)
websocket_api.async_register_command(hass, tts_info)
hass.http.register_view(GoogleActionsSyncView)
hass.http.register_view(CloudLoginView)
hass.http.register_view(CloudLogoutView)
hass.http.register_view(CloudRegisterView)
hass.http.register_view(CloudResendConfirmView)
hass.http.register_view(CloudForgotPasswordView)
_CLOUD_ERRORS.update(
{
auth.UserNotFound: (HTTPStatus.BAD_REQUEST, "User does not exist."),
auth.UserNotConfirmed: (HTTPStatus.BAD_REQUEST, "Email not confirmed."),
auth.UserExists: (
HTTPStatus.BAD_REQUEST,
"An account with the given email already exists.",
),
auth.Unauthenticated: (HTTPStatus.UNAUTHORIZED, "Authentication failed."),
auth.PasswordChangeRequired: (
HTTPStatus.BAD_REQUEST,
"Password change required.",
),
}
)
def _handle_cloud_errors(handler):
"""Webview decorator to handle auth errors."""
@wraps(handler)
async def error_handler(view, request, *args, **kwargs):
"""Handle exceptions that raise from the wrapped request handler."""
try:
result = await handler(view, request, *args, **kwargs)
return result
except Exception as err: # pylint: disable=broad-except
status, msg = _process_cloud_exception(err, request.path)
return view.json_message(
msg, status_code=status, message_code=err.__class__.__name__.lower()
)
return error_handler
def _ws_handle_cloud_errors(handler):
"""Websocket decorator to handle auth errors."""
@wraps(handler)
async def error_handler(hass, connection, msg):
"""Handle exceptions that raise from the wrapped handler."""
try:
return await handler(hass, connection, msg)
except Exception as err: # pylint: disable=broad-except
err_status, err_msg = _process_cloud_exception(err, msg["type"])
connection.send_error(msg["id"], err_status, err_msg)
return error_handler
def _process_cloud_exception(exc, where):
"""Process a cloud exception."""
err_info = None
for err, value_info in _CLOUD_ERRORS.items():
if isinstance(exc, err):
err_info = value_info
break
if err_info is None:
_LOGGER.exception("Unexpected error processing request for %s", where)
err_info = (HTTPStatus.BAD_GATEWAY, f"Unexpected error: {exc}")
return err_info
class GoogleActionsSyncView(HomeAssistantView):
"""Trigger a Google Actions Smart Home Sync."""
url = "/api/cloud/google_actions/sync"
name = "api:cloud:google_actions/sync"
@_handle_cloud_errors
async def post(self, request):
"""Trigger a Google Actions sync."""
hass = request.app["hass"]
cloud: Cloud = hass.data[DOMAIN]
gconf = await cloud.client.get_google_config()
status = await gconf.async_sync_entities(gconf.agent_user_id)
return self.json({}, status_code=status)
class CloudLoginView(HomeAssistantView):
"""Login to Home Assistant cloud."""
url = "/api/cloud/login"
name = "api:cloud:login"
@_handle_cloud_errors
@RequestDataValidator(
vol.Schema({vol.Required("email"): str, vol.Required("password"): str})
)
async def post(self, request, data):
"""Handle login request."""
hass = request.app["hass"]
cloud = hass.data[DOMAIN]
await cloud.login(data["email"], data["password"])
return self.json({"success": True})
class CloudLogoutView(HomeAssistantView):
"""Log out of the Home Assistant cloud."""
url = "/api/cloud/logout"
name = "api:cloud:logout"
@_handle_cloud_errors
async def post(self, request):
"""Handle logout request."""
hass = request.app["hass"]
cloud = hass.data[DOMAIN]
async with async_timeout.timeout(REQUEST_TIMEOUT):
await cloud.logout()
return self.json_message("ok")
class CloudRegisterView(HomeAssistantView):
"""Register on the Home Assistant cloud."""
url = "/api/cloud/register"
name = "api:cloud:register"
@_handle_cloud_errors
@RequestDataValidator(
vol.Schema(
{
vol.Required("email"): str,
vol.Required("password"): vol.All(str, vol.Length(min=6)),
}
)
)
async def post(self, request, data):
"""Handle registration request."""
hass = request.app["hass"]
cloud = hass.data[DOMAIN]
client_metadata = None
if location_info := await async_detect_location_info(
async_get_clientsession(hass)
):
client_metadata = {
"NC_COUNTRY_CODE": location_info.country_code,
"NC_REGION_CODE": location_info.region_code,
"NC_ZIP_CODE": location_info.zip_code,
}
async with async_timeout.timeout(REQUEST_TIMEOUT):
await cloud.auth.async_register(
data["email"],
data["password"],
client_metadata=client_metadata,
)
return self.json_message("ok")
class CloudResendConfirmView(HomeAssistantView):
"""Resend email confirmation code."""
url = "/api/cloud/resend_confirm"
name = "api:cloud:resend_confirm"
@_handle_cloud_errors
@RequestDataValidator(vol.Schema({vol.Required("email"): str}))
async def post(self, request, data):
"""Handle resending confirm email code request."""
hass = request.app["hass"]
cloud = hass.data[DOMAIN]
async with async_timeout.timeout(REQUEST_TIMEOUT):
await cloud.auth.async_resend_email_confirm(data["email"])
return self.json_message("ok")
class CloudForgotPasswordView(HomeAssistantView):
"""View to start Forgot Password flow.."""
url = "/api/cloud/forgot_password"
name = "api:cloud:forgot_password"
@_handle_cloud_errors
@RequestDataValidator(vol.Schema({vol.Required("email"): str}))
async def post(self, request, data):
"""Handle forgot password request."""
hass = request.app["hass"]
cloud = hass.data[DOMAIN]
async with async_timeout.timeout(REQUEST_TIMEOUT):
await cloud.auth.async_forgot_password(data["email"])
return self.json_message("ok")
@websocket_api.websocket_command({vol.Required("type"): "cloud/status"})
@websocket_api.async_response
async def websocket_cloud_status(hass, connection, msg):
"""Handle request for account info.
Async friendly.
"""
cloud = hass.data[DOMAIN]
connection.send_message(
websocket_api.result_message(msg["id"], await _account_data(hass, cloud))
)
def _require_cloud_login(handler):
"""Websocket decorator that requires cloud to be logged in."""
@wraps(handler)
def with_cloud_auth(hass, connection, msg):
"""Require to be logged into the cloud."""
cloud = hass.data[DOMAIN]
if not cloud.is_logged_in:
connection.send_message(
websocket_api.error_message(
msg["id"], "not_logged_in", "You need to be logged in to the cloud."
)
)
return
handler(hass, connection, msg)
return with_cloud_auth
@_require_cloud_login
@websocket_api.websocket_command({vol.Required("type"): "cloud/subscription"})
@websocket_api.async_response
async def websocket_subscription(hass, connection, msg):
"""Handle request for account info."""
cloud = hass.data[DOMAIN]
try:
async with async_timeout.timeout(REQUEST_TIMEOUT):
data = await cloud_api.async_subscription_info(cloud)
except aiohttp.ClientError:
connection.send_error(
msg["id"], "request_failed", "Failed to request subscription"
)
else:
connection.send_result(msg["id"], data)
@_require_cloud_login
@websocket_api.websocket_command(
{
vol.Required("type"): "cloud/update_prefs",
vol.Optional(PREF_ENABLE_GOOGLE): bool,
vol.Optional(PREF_ENABLE_ALEXA): bool,
vol.Optional(PREF_ALEXA_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_REPORT_STATE): bool,
vol.Optional(PREF_ALEXA_DEFAULT_EXPOSE): [str],
vol.Optional(PREF_GOOGLE_DEFAULT_EXPOSE): [str],
vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str),
vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All(
vol.Coerce(tuple), vol.In(MAP_VOICE)
),
}
)
@websocket_api.async_response
async def websocket_update_prefs(hass, connection, msg):
"""Handle request for account info."""
cloud = hass.data[DOMAIN]
changes = dict(msg)
changes.pop("id")
changes.pop("type")
# If we turn alexa linking on, validate that we can fetch access token
if changes.get(PREF_ALEXA_REPORT_STATE):
alexa_config = await cloud.client.get_alexa_config()
try:
async with async_timeout.timeout(10):
await alexa_config.async_get_access_token()
except asyncio.TimeoutError:
connection.send_error(
msg["id"], "alexa_timeout", "Timeout validating Alexa access token."
)
return
except (alexa_errors.NoTokenAvailable, alexa_errors.RequireRelink):
connection.send_error(
msg["id"],
"alexa_relink",
"Please go to the Alexa app and re-link the Home Assistant "
"skill and then try to enable state reporting.",
)
await alexa_config.set_authorized(False)
return
await alexa_config.set_authorized(True)
await cloud.client.prefs.async_update(**changes)
connection.send_message(websocket_api.result_message(msg["id"]))
@_require_cloud_login
@websocket_api.websocket_command(
{
vol.Required("type"): "cloud/cloudhook/create",
vol.Required("webhook_id"): str,
}
)
@websocket_api.async_response
@_ws_handle_cloud_errors
async def websocket_hook_create(hass, connection, msg):
"""Handle request for account info."""
cloud = hass.data[DOMAIN]
hook = await cloud.cloudhooks.async_create(msg["webhook_id"], False)
connection.send_message(websocket_api.result_message(msg["id"], hook))
@_require_cloud_login
@websocket_api.websocket_command(
{
vol.Required("type"): "cloud/cloudhook/delete",
vol.Required("webhook_id"): str,
}
)
@websocket_api.async_response
@_ws_handle_cloud_errors
async def websocket_hook_delete(hass, connection, msg):
"""Handle request for account info."""
cloud = hass.data[DOMAIN]
await cloud.cloudhooks.async_delete(msg["webhook_id"])
connection.send_message(websocket_api.result_message(msg["id"]))
async def _account_data(hass: HomeAssistant, cloud: Cloud):
"""Generate the auth data JSON response."""
if not cloud.is_logged_in:
return {"logged_in": False, "cloud": STATE_DISCONNECTED}
claims = cloud.claims
client = cloud.client
remote = cloud.remote
alexa_config = await client.get_alexa_config()
google_config = await client.get_google_config()
# Load remote certificate
if remote.certificate:
certificate = attr.asdict(remote.certificate)
else:
certificate = None
return {
"alexa_entities": client.alexa_user_config["filter"].config,
"alexa_registered": alexa_config.authorized,
"cloud": cloud.iot.state,
"email": claims["email"],
"google_entities": client.google_user_config["filter"].config,
"google_registered": google_config.has_registered_user_agent,
"logged_in": True,
"prefs": client.prefs.as_dict(),
"remote_certificate": certificate,
"remote_connected": remote.is_connected,
"remote_domain": remote.instance_domain,
"http_use_ssl": hass.config.api.use_ssl,
}
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command({"type": "cloud/remote/connect"})
@websocket_api.async_response
@_ws_handle_cloud_errors
async def websocket_remote_connect(hass, connection, msg):
"""Handle request for connect remote."""
cloud = hass.data[DOMAIN]
await cloud.client.prefs.async_update(remote_enabled=True)
connection.send_result(msg["id"], await _account_data(hass, cloud))
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command({"type": "cloud/remote/disconnect"})
@websocket_api.async_response
@_ws_handle_cloud_errors
async def websocket_remote_disconnect(hass, connection, msg):
"""Handle request for disconnect remote."""
cloud = hass.data[DOMAIN]
await cloud.client.prefs.async_update(remote_enabled=False)
connection.send_result(msg["id"], await _account_data(hass, cloud))
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command({"type": "cloud/google_assistant/entities"})
@websocket_api.async_response
@_ws_handle_cloud_errors
async def google_assistant_list(hass, connection, msg):
"""List all google assistant entities."""
cloud = hass.data[DOMAIN]
gconf = await cloud.client.get_google_config()
entities = google_helpers.async_get_entities(hass, gconf)
result = []
for entity in entities:
result.append(
{
"entity_id": entity.entity_id,
"traits": [trait.name for trait in entity.traits()],
"might_2fa": entity.might_2fa_traits(),
}
)
connection.send_result(msg["id"], result)
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command(
{
"type": "cloud/google_assistant/entities/update",
"entity_id": str,
vol.Optional("should_expose"): vol.Any(None, bool),
vol.Optional("override_name"): str,
vol.Optional("aliases"): [str],
vol.Optional("disable_2fa"): bool,
}
)
@websocket_api.async_response
@_ws_handle_cloud_errors
async def google_assistant_update(hass, connection, msg):
"""Update google assistant config."""
cloud = hass.data[DOMAIN]
changes = dict(msg)
changes.pop("type")
changes.pop("id")
await cloud.client.prefs.async_update_google_entity_config(**changes)
connection.send_result(
msg["id"], cloud.client.prefs.google_entity_configs.get(msg["entity_id"])
)
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command({"type": "cloud/alexa/entities"})
@websocket_api.async_response
@_ws_handle_cloud_errors
async def alexa_list(hass, connection, msg):
"""List all alexa entities."""
cloud = hass.data[DOMAIN]
alexa_config = await cloud.client.get_alexa_config()
entities = alexa_entities.async_get_entities(hass, alexa_config)
result = []
for entity in entities:
result.append(
{
"entity_id": entity.entity_id,
"display_categories": entity.default_display_categories(),
"interfaces": [ifc.name() for ifc in entity.interfaces()],
}
)
connection.send_result(msg["id"], result)
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command(
{
"type": "cloud/alexa/entities/update",
"entity_id": str,
vol.Optional("should_expose"): vol.Any(None, bool),
}
)
@websocket_api.async_response
@_ws_handle_cloud_errors
async def alexa_update(hass, connection, msg):
"""Update alexa entity config."""
cloud = hass.data[DOMAIN]
changes = dict(msg)
changes.pop("type")
changes.pop("id")
await cloud.client.prefs.async_update_alexa_entity_config(**changes)
connection.send_result(
msg["id"], cloud.client.prefs.alexa_entity_configs.get(msg["entity_id"])
)
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command({"type": "cloud/alexa/sync"})
@websocket_api.async_response
async def alexa_sync(hass, connection, msg):
"""Sync with Alexa."""
cloud = hass.data[DOMAIN]
alexa_config = await cloud.client.get_alexa_config()
async with async_timeout.timeout(10):
try:
success = await alexa_config.async_sync_entities()
except alexa_errors.NoTokenAvailable:
connection.send_error(
msg["id"],
"alexa_relink",
"Please go to the Alexa app and re-link the Home Assistant skill.",
)
return
if success:
connection.send_result(msg["id"])
else:
connection.send_error(msg["id"], ws_const.ERR_UNKNOWN_ERROR, "Unknown error")
@websocket_api.websocket_command({"type": "cloud/thingtalk/convert", "query": str})
@websocket_api.async_response
async def thingtalk_convert(hass, connection, msg):
"""Convert a query."""
cloud = hass.data[DOMAIN]
async with async_timeout.timeout(10):
try:
connection.send_result(
msg["id"], await thingtalk.async_convert(cloud, msg["query"])
)
except thingtalk.ThingTalkConversionError as err:
connection.send_error(msg["id"], ws_const.ERR_UNKNOWN_ERROR, str(err))
@websocket_api.websocket_command({"type": "cloud/tts/info"})
def tts_info(hass, connection, msg):
"""Fetch available tts info."""
connection.send_result(
msg["id"], {"languages": [(lang, gender.value) for lang, gender in MAP_VOICE]}
)