Skip to content
36 changes: 35 additions & 1 deletion homeassistant/components/cloud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from datetime import datetime, timedelta
from enum import Enum
import logging
from typing import cast
from typing import Any, cast

from hass_nabucasa import Cloud
import voluptuous as vol
Expand Down Expand Up @@ -69,12 +69,15 @@
DOMAIN,
MODE_DEV,
MODE_PROD,
PREF_CLOUDHOOKS,
)
from .helpers import FixedSizeQueueLogHandler
from .prefs import CloudPreferences
from .repairs import async_manage_legacy_subscription_issue
from .subscription import async_subscription_info

_LOGGER = logging.getLogger(__name__)

DEFAULT_MODE = MODE_PROD

PLATFORMS = [Platform.BINARY_SENSOR, Platform.STT, Platform.TTS]
Expand Down Expand Up @@ -242,6 +245,37 @@ async def async_delete_cloudhook(hass: HomeAssistant, webhook_id: str) -> None:
await hass.data[DATA_CLOUD].cloudhooks.async_delete(webhook_id)


@callback
def async_listen_cloudhook_change(
hass: HomeAssistant,
webhook_id: str,
on_change: Callable[[dict[str, Any] | None], None],
) -> Callable:
"""Listen for cloudhook changes for the given webhook and notify when modified or deleted.

Args:
hass: Home Assistant instance
webhook_id: The webhook ID to monitor for changes
on_change: Callback invoked when the cloudhook changes. Receives the
cloudhook when updated, or None when deleted.

Returns:
Callable that unsubscribes from cloudhook change notifications
"""
_LOGGER.debug("Setting up cloudhook deletion listener for %s", webhook_id)

if DATA_CLOUD not in hass.data:
Comment thread
TimoPtr marked this conversation as resolved.
Outdated
# If cloud is not available, return a no-op unsubscribe
return lambda: None

async def _async_prefs_updated(prefs: CloudPreferences) -> None:
"""Handle cloud preferences update."""
if PREF_CLOUDHOOKS in prefs.last_updated:
on_change(prefs.cloudhooks.get(webhook_id))

return hass.data[DATA_CLOUD].client.prefs.async_listen_updates(_async_prefs_updated)


@bind_hass
@callback
def async_remote_ui_url(hass: HomeAssistant) -> str:
Expand Down
43 changes: 38 additions & 5 deletions homeassistant/components/mobile_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from contextlib import suppress
from functools import partial
import logging
from typing import Any

from homeassistant.auth import EVENT_USER_REMOVED
Expand Down Expand Up @@ -55,6 +56,8 @@
from .util import async_create_cloud_hook, supports_push
from .webhook import handle_webhook

_LOGGER = logging.getLogger(__name__)

PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR]

CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
Expand Down Expand Up @@ -131,25 +134,55 @@ 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)

def clean_cloudhook() -> None:
"""Clean up cloudhook from config entry."""
if CONF_CLOUDHOOK_URL in entry.data:
data = dict(entry.data)
data.pop(CONF_CLOUDHOOK_URL)
hass.config_entries.async_update_entry(entry, data=data)

def on_cloudhook_change(cloudhook: dict[str, Any] | None) -> None:
"""Handle cloudhook changes."""
if cloudhook:
if (
CONF_CLOUDHOOK_URL in entry.data
and entry.data[CONF_CLOUDHOOK_URL] == cloudhook[CONF_CLOUDHOOK_URL]
Comment thread
TimoPtr marked this conversation as resolved.
Outdated
):
return

hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_CLOUDHOOK_URL: cloudhook[CONF_CLOUDHOOK_URL]},
)
else:
clean_cloudhook()

async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
if (
state is cloud.CloudConnectionState.CLOUD_CONNECTED
and CONF_CLOUDHOOK_URL not in entry.data
):
await async_create_cloud_hook(hass, webhook_id, entry)
await async_create_cloud_hook(hass, webhook_id)
Comment thread
TimoPtr marked this conversation as resolved.
Outdated
elif (
state is cloud.CloudConnectionState.CLOUD_DISCONNECTED
and not cloud.async_is_logged_in(hass)
):
clean_cloudhook()

entry.async_on_unload(
cloud.async_listen_cloudhook_change(hass, webhook_id, on_cloudhook_change)
)

if cloud.async_is_logged_in(hass):
if (
CONF_CLOUDHOOK_URL not in entry.data
and cloud.async_active_subscription(hass)
and cloud.async_is_connected(hass)
):
await async_create_cloud_hook(hass, webhook_id, entry)
await async_create_cloud_hook(hass, webhook_id)
Comment thread
TimoPtr marked this conversation as resolved.
Outdated
elif CONF_CLOUDHOOK_URL in entry.data:
# If we have a cloudhook but no longer logged in to the cloud, remove it from the entry
data = dict(entry.data)
data.pop(CONF_CLOUDHOOK_URL)
hass.config_entries.async_update_entry(entry, data=data)
clean_cloudhook()

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

Expand Down
4 changes: 1 addition & 3 deletions homeassistant/components/mobile_app/http_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,7 @@ async def post(self, request: Request, data: dict) -> Response:
webhook_id = secrets.token_hex()

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

data[CONF_WEBHOOK_ID] = webhook_id

Expand Down
13 changes: 2 additions & 11 deletions homeassistant/components/mobile_app/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,13 @@
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 @@ -63,14 +61,7 @@ def get_notify_service(hass: HomeAssistant, webhook_id: str) -> str | None:
_CLOUD_HOOK_LOCK = asyncio.Lock()


async def async_create_cloud_hook(
hass: HomeAssistant, webhook_id: str, entry: ConfigEntry | None
) -> str:
async def async_create_cloud_hook(hass: HomeAssistant, webhook_id: str) -> str:
"""Create a cloud hook."""
async with _CLOUD_HOOK_LOCK:
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
return await cloud.async_get_or_create_cloudhook(hass, webhook_id)
5 changes: 2 additions & 3 deletions homeassistant/components/mobile_app/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -756,10 +756,9 @@ async def webhook_get_config(
"theme_color": MANIFEST_JSON["theme_color"],
}

if CONF_CLOUDHOOK_URL in config_entry.data:
resp[CONF_CLOUDHOOK_URL] = config_entry.data[CONF_CLOUDHOOK_URL]

if cloud.async_active_subscription(hass):
if CONF_CLOUDHOOK_URL in config_entry.data:
resp[CONF_CLOUDHOOK_URL] = config_entry.data[CONF_CLOUDHOOK_URL]
with suppress(cloud.CloudNotAvailable):
resp[CONF_REMOTE_UI_URL] = cloud.async_remote_ui_url(hass)

Expand Down
99 changes: 99 additions & 0 deletions tests/components/cloud/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
CloudNotAvailable,
CloudNotConnected,
async_get_or_create_cloudhook,
async_listen_cloudhook_change,
async_listen_connection_change,
async_remote_ui_url,
)
Expand Down Expand Up @@ -311,3 +312,101 @@ async def test_cloud_logout(
await hass.async_block_till_done()

assert cloud.is_logged_in is False


async def test_async_listen_cloudhook_change(
hass: HomeAssistant,
cloud: MagicMock,
set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
) -> None:
"""Test async_listen_cloudhook_change."""
assert await async_setup_component(hass, "cloud", {"cloud": {}})
await hass.async_block_till_done()
await cloud.login("test-user", "test-pass")

webhook_id = "mock-webhook-id"
cloudhook_url = "https://cloudhook.nabu.casa/abcdefg"

# Set up initial cloudhooks state
await set_cloud_prefs(
{
PREF_CLOUDHOOKS: {
webhook_id: {
"webhook_id": webhook_id,
"cloudhook_id": "random-id",
"cloudhook_url": cloudhook_url,
"managed": True,
}
}
}
)

# Track cloudhook changes
changes = []

def on_change(cloudhook: dict[str, Any] | None) -> None:
"""Handle cloudhook change."""
changes.append(cloudhook)

# Register the change listener
unsubscribe = async_listen_cloudhook_change(hass, webhook_id, on_change)

# Verify no changes yet
assert len(changes) == 0

# Delete the cloudhook by updating prefs
await set_cloud_prefs({PREF_CLOUDHOOKS: {}})
await hass.async_block_till_done()

# Verify deletion callback was called with None
assert len(changes) == 1
assert changes[-1] is None

# Add cloudhook back
cloudhook_data = {
"webhook_id": webhook_id,
"cloudhook_id": "random-id",
"cloudhook_url": cloudhook_url,
"managed": True,
}
await set_cloud_prefs({PREF_CLOUDHOOKS: {webhook_id: cloudhook_data}})
await hass.async_block_till_done()

# Verify callback called with cloudhook data
assert len(changes) == 2
assert changes[-1] == cloudhook_data

# Unsubscribe from listener
unsubscribe()

# Delete cloudhook again
await set_cloud_prefs({PREF_CLOUDHOOKS: {}})
await hass.async_block_till_done()

# Verify change callback was NOT called after unsubscribe
assert len(changes) == 2


async def test_async_listen_cloudhook_change_no_cloud(
Comment thread
TimoPtr marked this conversation as resolved.
Outdated
hass: HomeAssistant,
) -> None:
"""Test async_listen_cloudhook_change when cloud is not available."""
webhook_id = "mock-webhook-id"
change_called = False

def on_change(cloudhook: dict[str, Any] | None) -> None:
"""Handle cloudhook change."""
nonlocal change_called
change_called = True

# Should return a no-op unsubscribe when cloud is not available
unsubscribe = async_listen_cloudhook_change(hass, webhook_id, on_change)

# Verify it returns a callable
assert callable(unsubscribe)

# Call unsubscribe (should not raise)
unsubscribe()

# Verify change callback was never called
assert not change_called
Loading
Loading