Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix mobile_app cloudhook creation #107068

Merged
merged 8 commits into from Jan 5, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
17 changes: 17 additions & 0 deletions homeassistant/components/cloud/__init__.py
Expand Up @@ -5,6 +5,7 @@
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta
from enum import Enum
from typing import cast

from hass_nabucasa import Cloud
import voluptuous as vol
Expand Down Expand Up @@ -176,6 +177,22 @@ def async_active_subscription(hass: HomeAssistant) -> bool:
return async_is_logged_in(hass) and not hass.data[DOMAIN].subscription_expired


async def async_get_or_create_cloudhook(hass: HomeAssistant, webhook_id: str) -> str:
"""Get or create a cloudhook."""
if not async_is_connected(hass):
raise CloudNotConnected

if not async_is_logged_in(hass):
raise CloudNotAvailable

cloud: Cloud[CloudClient] = hass.data[DOMAIN]
cloudhooks = cloud.client.cloudhooks
if hook := cloudhooks.get(webhook_id):
return cast(str, hook["cloudhook_url"])

return await async_create_cloudhook(hass, webhook_id)


@bind_hass
async def async_create_cloudhook(hass: HomeAssistant, webhook_id: str) -> str:
"""Create a cloudhook."""
Expand Down
13 changes: 4 additions & 9 deletions homeassistant/components/mobile_app/__init__.py
Expand Up @@ -36,6 +36,7 @@
)
from .helpers import savable_state
from .http_api import RegistrationsView
from .util import create_cloud_hook
from .webhook import handle_webhook

PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER]
Expand Down Expand Up @@ -103,26 +104,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}"
webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook)

async def create_cloud_hook() -> None:
"""Create a cloud hook."""
hook = await cloud.async_create_cloudhook(hass, webhook_id)
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_CLOUDHOOK_URL: hook}
)

async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
if (
state is cloud.CloudConnectionState.CLOUD_CONNECTED
and CONF_CLOUDHOOK_URL not in entry.data
):
await create_cloud_hook()
await create_cloud_hook(hass, webhook_id, entry)

if (
CONF_CLOUDHOOK_URL not in entry.data
and cloud.async_active_subscription(hass)
and cloud.async_is_connected(hass)
):
await create_cloud_hook()
await create_cloud_hook(hass, webhook_id, entry)

entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook))

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
Expand Down
5 changes: 2 additions & 3 deletions homeassistant/components/mobile_app/http_api.py
Expand Up @@ -35,6 +35,7 @@
SCHEMA_APP_DATA,
)
from .helpers import supports_encryption
from .util import create_cloud_hook


class RegistrationsView(HomeAssistantView):
Expand Down Expand Up @@ -69,9 +70,7 @@
webhook_id = secrets.token_hex()

if cloud.async_active_subscription(hass):
data[CONF_CLOUDHOOK_URL] = await cloud.async_create_cloudhook(
hass, webhook_id
)
data[CONF_CLOUDHOOK_URL] = await create_cloud_hook(hass, webhook_id, None)

Check warning on line 73 in homeassistant/components/mobile_app/http_api.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/mobile_app/http_api.py#L73

Added line #L73 was not covered by tests

data[CONF_WEBHOOK_ID] = webhook_id

Expand Down
21 changes: 21 additions & 0 deletions homeassistant/components/mobile_app/util.py
@@ -1,15 +1,19 @@
"""Mobile app utility functions."""
from __future__ import annotations

import asyncio
from typing import TYPE_CHECKING

from homeassistant.components import cloud
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback

from .const import (
ATTR_APP_DATA,
ATTR_PUSH_TOKEN,
ATTR_PUSH_URL,
ATTR_PUSH_WEBSOCKET_CHANNEL,
CONF_CLOUDHOOK_URL,
DATA_CONFIG_ENTRIES,
DATA_DEVICES,
DATA_NOTIFY,
Expand Down Expand Up @@ -53,3 +57,20 @@ def get_notify_service(hass: HomeAssistant, webhook_id: str) -> str | None:
return target_service

return None


_CLOUD_HOOK_LOCK = asyncio.Lock()


@callback
edenhaus marked this conversation as resolved.
Show resolved Hide resolved
async def create_cloud_hook(
edenhaus marked this conversation as resolved.
Show resolved Hide resolved
hass: HomeAssistant, webhook_id: str, entry: ConfigEntry | None
) -> str:
"""Create a cloud hook."""
async with _CLOUD_HOOK_LOCK:
edenhaus marked this conversation as resolved.
Show resolved Hide resolved
hook = await cloud.async_get_or_create_cloudhook(hass, webhook_id)
if entry:
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_CLOUDHOOK_URL: hook}
)
return hook
49 changes: 48 additions & 1 deletion tests/components/cloud/test_init.py
Expand Up @@ -6,8 +6,9 @@
import pytest

from homeassistant.components import cloud
from homeassistant.components.cloud.client import CloudClient
from homeassistant.components.cloud.const import DOMAIN
from homeassistant.components.cloud.prefs import STORAGE_KEY
from homeassistant.components.cloud.prefs import STORAGE_KEY, CloudPreferences
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import Unauthorized
Expand Down Expand Up @@ -214,3 +215,49 @@ async def test_remote_ui_url(hass: HomeAssistant, mock_cloud_fixture) -> None:
cl.client.prefs._prefs["remote_domain"] = "example.com"

assert cloud.async_remote_ui_url(hass) == "https://example.com"


async def test_async_get_or_create_cloudhook(
hass: HomeAssistant, mock_cloud_fixture: CloudPreferences
) -> None:
"""Test async_get_or_create_cloudhook."""
cl: Cloud[CloudClient] = hass.data[DOMAIN]
edenhaus marked this conversation as resolved.
Show resolved Hide resolved
webhook_id = "mock-webhook-id"

# Not connected
with pytest.raises(cloud.CloudNotConnected):
await cloud.async_get_or_create_cloudhook(hass, webhook_id)

# Simulate connected
cl.iot.state = "connected"

# Not logged in
with pytest.raises(cloud.CloudNotAvailable):
await cloud.async_get_or_create_cloudhook(hass, webhook_id)

cloudhook_url = "https://cloudhook.nabu.casa/abcdefg"

with patch.object(cloud, "async_is_logged_in", return_value=True), patch.object(
cloud, "async_create_cloudhook", return_value=cloudhook_url
) as async_create_cloudhook_mock:
# create cloudhook as it does not exist
assert (
await cloud.async_get_or_create_cloudhook(hass, webhook_id)
) == cloudhook_url
async_create_cloudhook_mock.assert_called_once_with(hass, webhook_id)

mock_cloud_fixture._prefs[cloud.const.PREF_CLOUDHOOKS] = {
edenhaus marked this conversation as resolved.
Show resolved Hide resolved
webhook_id: {
"webhook_id": webhook_id,
"cloudhook_id": "random-id",
"cloudhook_url": cloudhook_url,
"managed": True,
}
}
async_create_cloudhook_mock.reset_mock()

# get cloudhook as it exists
assert (
await cloud.async_get_or_create_cloudhook(hass, webhook_id) == cloudhook_url
)
async_create_cloudhook_mock.assert_not_called()
10 changes: 6 additions & 4 deletions tests/components/mobile_app/test_init.py
Expand Up @@ -88,15 +88,17 @@ async def _test_create_cloud_hook(
), patch(
"homeassistant.components.cloud.async_is_connected", return_value=True
), patch(
"homeassistant.components.cloud.async_create_cloudhook", autospec=True
) as mock_create_cloudhook:
"homeassistant.components.cloud.async_get_or_create_cloudhook", autospec=True
) as mock_async_get_or_create_cloudhook:
cloud_hook = "https://hook-url"
mock_create_cloudhook.return_value = cloud_hook
mock_async_get_or_create_cloudhook.return_value = cloud_hook

assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
await additional_steps(config_entry, mock_create_cloudhook, cloud_hook)
await additional_steps(
config_entry, mock_async_get_or_create_cloudhook, cloud_hook
)


async def test_create_cloud_hook_on_setup(
Expand Down