Skip to content

Commit 733526f

Browse files
TimoPtrfrenck
authored andcommitted
Rework CloudhookURL setup for mobile app (home-assistant#156940)
1 parent 1ef001f commit 733526f

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
@@ -85,6 +85,10 @@
8585
"CLOUD_CONNECTION_STATE"
8686
)
8787

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

9094
ALEXA_ENTITY_SCHEMA = vol.Schema(
@@ -240,6 +244,24 @@ async def async_delete_cloudhook(hass: HomeAssistant, webhook_id: str) -> None:
240244
await hass.data[DATA_CLOUD].cloudhooks.async_delete(webhook_id)
241245

242246

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

288310
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
289311

290-
_remote_handle_prefs_updated(cloud)
312+
_handle_prefs_updated(hass, cloud)
291313
_setup_services(hass, prefs)
292314

293315
async def async_startup_repairs(_: datetime) -> None:
@@ -371,26 +393,32 @@ async def _on_initialized() -> None:
371393

372394

373395
@callback
374-
def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None:
375-
"""Handle remote preferences updated."""
376-
cur_pref = cloud.client.prefs.remote_enabled
396+
def _handle_prefs_updated(hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None:
397+
"""Register handler for cloud preferences updates."""
398+
cur_remote_enabled = cloud.client.prefs.remote_enabled
399+
cur_cloudhooks = cloud.client.prefs.cloudhooks
377400
lock = asyncio.Lock()
378401

379-
# Sync remote connection with prefs
380-
async def remote_prefs_updated(prefs: CloudPreferences) -> None:
381-
"""Update remote status."""
382-
nonlocal cur_pref
402+
async def on_prefs_updated(prefs: CloudPreferences) -> None:
403+
"""Handle cloud preferences updates."""
404+
nonlocal cur_remote_enabled
405+
nonlocal cur_cloudhooks
383406

407+
# Lock protects cur_ state variables from concurrent updates
384408
async with lock:
385-
if prefs.remote_enabled == cur_pref:
409+
if cur_cloudhooks != prefs.cloudhooks:
410+
cur_cloudhooks = prefs.cloudhooks
411+
async_dispatcher_send(hass, _SIGNAL_CLOUDHOOKS_UPDATED, cur_cloudhooks)
412+
413+
if prefs.remote_enabled == cur_remote_enabled:
386414
return
387415

388-
if cur_pref := prefs.remote_enabled:
416+
if cur_remote_enabled := prefs.remote_enabled:
389417
await cloud.remote.connect()
390418
else:
391419
await cloud.remote.disconnect()
392420

393-
cloud.client.prefs.async_listen_updates(remote_prefs_updated)
421+
cloud.client.prefs.async_listen_updates(on_prefs_updated)
394422

395423

396424
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
@@ -128,12 +128,41 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
128128
registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}"
129129
webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook)
130130

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

138167
if cloud.async_is_logged_in(hass):
139168
if (
@@ -144,9 +173,7 @@ async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
144173
await async_create_cloud_hook(hass, webhook_id, entry)
145174
elif CONF_CLOUDHOOK_URL in entry.data:
146175
# If we have a cloudhook but no longer logged in to the cloud, remove it from the entry
147-
data = dict(entry.data)
148-
data.pop(CONF_CLOUDHOOK_URL)
149-
hass.config_entries.async_update_entry(entry, data=data)
176+
clean_cloudhook()
150177

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

homeassistant/components/mobile_app/webhook.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -738,10 +738,9 @@ async def webhook_get_config(
738738
"theme_color": MANIFEST_JSON["theme_color"],
739739
}
740740

741-
if CONF_CLOUDHOOK_URL in config_entry.data:
742-
resp[CONF_CLOUDHOOK_URL] = config_entry.data[CONF_CLOUDHOOK_URL]
743-
744741
if cloud.async_active_subscription(hass):
742+
if CONF_CLOUDHOOK_URL in config_entry.data:
743+
resp[CONF_CLOUDHOOK_URL] = config_entry.data[CONF_CLOUDHOOK_URL]
745744
with suppress(cloud.CloudNotAvailable):
746745
resp[CONF_REMOTE_UI_URL] = cloud.async_remote_ui_url(hass)
747746

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
)
@@ -304,3 +305,149 @@ async def test_cloud_logout(
304305
await hass.async_block_till_done()
305306

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

0 commit comments

Comments
 (0)