Skip to content

Commit 514a329

Browse files
authored
Rework CloudhookURL setup for mobile app (home-assistant#156940)
1 parent f2b8bb0 commit 514a329

File tree

6 files changed

+573
-28
lines changed

6 files changed

+573
-28
lines changed

homeassistant/components/cloud/__init__.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from datetime import datetime, timedelta
88
from enum import Enum
99
import logging
10-
from typing import cast
10+
from typing import Any, cast
1111

1212
from hass_nabucasa import Cloud
1313
import voluptuous as vol
@@ -86,6 +86,10 @@
8686
"CLOUD_CONNECTION_STATE"
8787
)
8888

89+
_SIGNAL_CLOUDHOOKS_UPDATED: SignalType[dict[str, Any]] = SignalType(
90+
"CLOUDHOOKS_UPDATED"
91+
)
92+
8993
STARTUP_REPAIR_DELAY = 1 # 1 hour
9094

9195
ALEXA_ENTITY_SCHEMA = vol.Schema(
@@ -242,6 +246,24 @@ async def async_delete_cloudhook(hass: HomeAssistant, webhook_id: str) -> None:
242246
await hass.data[DATA_CLOUD].cloudhooks.async_delete(webhook_id)
243247

244248

249+
@callback
250+
def async_listen_cloudhook_change(
251+
hass: HomeAssistant,
252+
webhook_id: str,
253+
on_change: Callable[[dict[str, Any] | None], None],
254+
) -> Callable[[], None]:
255+
"""Listen for cloudhook changes for the given webhook and notify when modified or deleted."""
256+
257+
@callback
258+
def _handle_cloudhooks_updated(cloudhooks: dict[str, Any]) -> None:
259+
"""Handle cloudhooks updated signal."""
260+
on_change(cloudhooks.get(webhook_id))
261+
262+
return async_dispatcher_connect(
263+
hass, _SIGNAL_CLOUDHOOKS_UPDATED, _handle_cloudhooks_updated
264+
)
265+
266+
245267
@bind_hass
246268
@callback
247269
def async_remote_ui_url(hass: HomeAssistant) -> str:
@@ -289,7 +311,7 @@ async def _shutdown(event: Event) -> None:
289311

290312
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
291313

292-
_remote_handle_prefs_updated(cloud)
314+
_handle_prefs_updated(hass, cloud)
293315
_setup_services(hass, prefs)
294316

295317
async def async_startup_repairs(_: datetime) -> None:
@@ -373,26 +395,32 @@ async def _on_initialized() -> None:
373395

374396

375397
@callback
376-
def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None:
377-
"""Handle remote preferences updated."""
378-
cur_pref = cloud.client.prefs.remote_enabled
398+
def _handle_prefs_updated(hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None:
399+
"""Register handler for cloud preferences updates."""
400+
cur_remote_enabled = cloud.client.prefs.remote_enabled
401+
cur_cloudhooks = cloud.client.prefs.cloudhooks
379402
lock = asyncio.Lock()
380403

381-
# Sync remote connection with prefs
382-
async def remote_prefs_updated(prefs: CloudPreferences) -> None:
383-
"""Update remote status."""
384-
nonlocal cur_pref
404+
async def on_prefs_updated(prefs: CloudPreferences) -> None:
405+
"""Handle cloud preferences updates."""
406+
nonlocal cur_remote_enabled
407+
nonlocal cur_cloudhooks
385408

409+
# Lock protects cur_ state variables from concurrent updates
386410
async with lock:
387-
if prefs.remote_enabled == cur_pref:
411+
if cur_cloudhooks != prefs.cloudhooks:
412+
cur_cloudhooks = prefs.cloudhooks
413+
async_dispatcher_send(hass, _SIGNAL_CLOUDHOOKS_UPDATED, cur_cloudhooks)
414+
415+
if prefs.remote_enabled == cur_remote_enabled:
388416
return
389417

390-
if cur_pref := prefs.remote_enabled:
418+
if cur_remote_enabled := prefs.remote_enabled:
391419
await cloud.remote.connect()
392420
else:
393421
await cloud.remote.disconnect()
394422

395-
cloud.client.prefs.async_listen_updates(remote_prefs_updated)
423+
cloud.client.prefs.async_listen_updates(on_prefs_updated)
396424

397425

398426
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

homeassistant/components/mobile_app/__init__.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,41 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
131131
registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}"
132132
webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook)
133133

134+
def clean_cloudhook() -> None:
135+
"""Clean up cloudhook from config entry."""
136+
if CONF_CLOUDHOOK_URL in entry.data:
137+
data = dict(entry.data)
138+
data.pop(CONF_CLOUDHOOK_URL)
139+
hass.config_entries.async_update_entry(entry, data=data)
140+
141+
def on_cloudhook_change(cloudhook: dict[str, Any] | None) -> None:
142+
"""Handle cloudhook changes."""
143+
if cloudhook:
144+
if entry.data.get(CONF_CLOUDHOOK_URL) == cloudhook[CONF_CLOUDHOOK_URL]:
145+
return
146+
147+
hass.config_entries.async_update_entry(
148+
entry,
149+
data={**entry.data, CONF_CLOUDHOOK_URL: cloudhook[CONF_CLOUDHOOK_URL]},
150+
)
151+
else:
152+
clean_cloudhook()
153+
134154
async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
135155
if (
136156
state is cloud.CloudConnectionState.CLOUD_CONNECTED
137157
and CONF_CLOUDHOOK_URL not in entry.data
138158
):
139159
await async_create_cloud_hook(hass, webhook_id, entry)
160+
elif (
161+
state is cloud.CloudConnectionState.CLOUD_DISCONNECTED
162+
and not cloud.async_is_logged_in(hass)
163+
):
164+
clean_cloudhook()
165+
166+
entry.async_on_unload(
167+
cloud.async_listen_cloudhook_change(hass, webhook_id, on_cloudhook_change)
168+
)
140169

141170
if cloud.async_is_logged_in(hass):
142171
if (
@@ -147,9 +176,7 @@ async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
147176
await async_create_cloud_hook(hass, webhook_id, entry)
148177
elif CONF_CLOUDHOOK_URL in entry.data:
149178
# If we have a cloudhook but no longer logged in to the cloud, remove it from the entry
150-
data = dict(entry.data)
151-
data.pop(CONF_CLOUDHOOK_URL)
152-
hass.config_entries.async_update_entry(entry, data=data)
179+
clean_cloudhook()
153180

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

homeassistant/components/mobile_app/webhook.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -756,10 +756,9 @@ async def webhook_get_config(
756756
"theme_color": MANIFEST_JSON["theme_color"],
757757
}
758758

759-
if CONF_CLOUDHOOK_URL in config_entry.data:
760-
resp[CONF_CLOUDHOOK_URL] = config_entry.data[CONF_CLOUDHOOK_URL]
761-
762759
if cloud.async_active_subscription(hass):
760+
if CONF_CLOUDHOOK_URL in config_entry.data:
761+
resp[CONF_CLOUDHOOK_URL] = config_entry.data[CONF_CLOUDHOOK_URL]
763762
with suppress(cloud.CloudNotAvailable):
764763
resp[CONF_REMOTE_UI_URL] = cloud.async_remote_ui_url(hass)
765764

tests/components/cloud/test_init.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
CloudNotAvailable,
1212
CloudNotConnected,
1313
async_get_or_create_cloudhook,
14+
async_listen_cloudhook_change,
1415
async_listen_connection_change,
1516
async_remote_ui_url,
1617
)
@@ -311,3 +312,149 @@ async def test_cloud_logout(
311312
await hass.async_block_till_done()
312313

313314
assert cloud.is_logged_in is False
315+
316+
317+
async def test_async_listen_cloudhook_change(
318+
hass: HomeAssistant,
319+
cloud: MagicMock,
320+
set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
321+
) -> None:
322+
"""Test async_listen_cloudhook_change."""
323+
assert await async_setup_component(hass, "cloud", {"cloud": {}})
324+
await hass.async_block_till_done()
325+
await cloud.login("test-user", "test-pass")
326+
327+
webhook_id = "mock-webhook-id"
328+
cloudhook_url = "https://cloudhook.nabu.casa/abcdefg"
329+
330+
# Set up initial cloudhooks state
331+
await set_cloud_prefs(
332+
{
333+
PREF_CLOUDHOOKS: {
334+
webhook_id: {
335+
"webhook_id": webhook_id,
336+
"cloudhook_id": "random-id",
337+
"cloudhook_url": cloudhook_url,
338+
"managed": True,
339+
}
340+
}
341+
}
342+
)
343+
344+
# Track cloudhook changes
345+
changes = []
346+
changeInvoked = False
347+
348+
def on_change(cloudhook: dict[str, Any] | None) -> None:
349+
"""Handle cloudhook change."""
350+
nonlocal changeInvoked
351+
changes.append(cloudhook)
352+
changeInvoked = True
353+
354+
# Register the change listener
355+
unsubscribe = async_listen_cloudhook_change(hass, webhook_id, on_change)
356+
357+
# Verify no changes yet
358+
assert len(changes) == 0
359+
assert changeInvoked is False
360+
361+
# Delete the cloudhook by updating prefs
362+
await set_cloud_prefs({PREF_CLOUDHOOKS: {}})
363+
await hass.async_block_till_done()
364+
365+
# Verify deletion callback was called with None
366+
assert len(changes) == 1
367+
assert changes[-1] is None
368+
assert changeInvoked is True
369+
370+
# Reset changeInvoked to detect next change
371+
changeInvoked = False
372+
373+
# Add cloudhook back
374+
cloudhook_data = {
375+
"webhook_id": webhook_id,
376+
"cloudhook_id": "random-id",
377+
"cloudhook_url": cloudhook_url,
378+
"managed": True,
379+
}
380+
await set_cloud_prefs({PREF_CLOUDHOOKS: {webhook_id: cloudhook_data}})
381+
await hass.async_block_till_done()
382+
383+
# Verify callback called with cloudhook data
384+
assert len(changes) == 2
385+
assert changes[-1] == cloudhook_data
386+
assert changeInvoked is True
387+
388+
# Reset changeInvoked to detect next change
389+
changeInvoked = False
390+
391+
# Update cloudhook data with same cloudhook should not trigger callback
392+
await set_cloud_prefs({PREF_CLOUDHOOKS: {webhook_id: cloudhook_data}})
393+
await hass.async_block_till_done()
394+
395+
assert changeInvoked is False
396+
397+
# Unsubscribe from listener
398+
unsubscribe()
399+
400+
# Delete cloudhook again
401+
await set_cloud_prefs({PREF_CLOUDHOOKS: {}})
402+
await hass.async_block_till_done()
403+
404+
# Verify change callback was NOT called after unsubscribe
405+
assert len(changes) == 2
406+
assert changeInvoked is False
407+
408+
409+
async def test_async_listen_cloudhook_change_cloud_setup_later(
410+
hass: HomeAssistant,
411+
cloud: MagicMock,
412+
set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
413+
) -> None:
414+
"""Test async_listen_cloudhook_change works when cloud is set up after listener registration."""
415+
webhook_id = "mock-webhook-id"
416+
cloudhook_url = "https://cloudhook.nabu.casa/abcdefg"
417+
418+
# Track cloudhook changes
419+
changes: list[dict[str, Any] | None] = []
420+
421+
def on_change(cloudhook: dict[str, Any] | None) -> None:
422+
"""Handle cloudhook change."""
423+
changes.append(cloudhook)
424+
425+
# Register listener BEFORE cloud is set up
426+
unsubscribe = async_listen_cloudhook_change(hass, webhook_id, on_change)
427+
428+
# Verify it returns a callable
429+
assert callable(unsubscribe)
430+
431+
# No changes yet since cloud isn't set up
432+
assert len(changes) == 0
433+
434+
# Now set up cloud
435+
assert await async_setup_component(hass, "cloud", {"cloud": {}})
436+
await hass.async_block_till_done()
437+
await cloud.login("test-user", "test-pass")
438+
439+
# Add a cloudhook - this should trigger the listener
440+
cloudhook_data = {
441+
"webhook_id": webhook_id,
442+
"cloudhook_id": "random-id",
443+
"cloudhook_url": cloudhook_url,
444+
"managed": True,
445+
}
446+
await set_cloud_prefs({PREF_CLOUDHOOKS: {webhook_id: cloudhook_data}})
447+
await hass.async_block_till_done()
448+
449+
# Verify the listener received the update
450+
assert len(changes) == 1
451+
assert changes[-1] == cloudhook_data
452+
453+
# Unsubscribe and verify no more updates
454+
unsubscribe()
455+
456+
await set_cloud_prefs({PREF_CLOUDHOOKS: {}})
457+
await hass.async_block_till_done()
458+
459+
# Should not receive update after unsubscribe
460+
assert len(changes) == 1

0 commit comments

Comments
 (0)