Skip to content

Commit f2f653e

Browse files
joostlekMartinHjelmare
authored andcommitted
Delete subscription on shutdown of SmartThings (#140135)
* Cache subscription url in SmartThings * Cache subscription url in SmartThings * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Bump pysmartthings to 2.7.1 * 2.7.2 --------- Co-authored-by: Martin Hjelmare <[email protected]>
1 parent b5c7bdd commit f2f653e

File tree

9 files changed

+270
-14
lines changed

9 files changed

+270
-14
lines changed

homeassistant/components/smartthings/__init__.py

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,18 @@
1616
Scene,
1717
SmartThings,
1818
SmartThingsAuthenticationFailedError,
19+
SmartThingsSinkError,
1920
Status,
2021
)
2122

2223
from homeassistant.config_entries import ConfigEntry
23-
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform
24-
from homeassistant.core import HomeAssistant
24+
from homeassistant.const import (
25+
CONF_ACCESS_TOKEN,
26+
CONF_TOKEN,
27+
EVENT_HOMEASSISTANT_STOP,
28+
Platform,
29+
)
30+
from homeassistant.core import Event, HomeAssistant
2531
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
2632
from homeassistant.helpers import device_registry as dr
2733
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -33,6 +39,7 @@
3339
from .const import (
3440
CONF_INSTALLED_APP_ID,
3541
CONF_LOCATION_ID,
42+
CONF_SUBSCRIPTION_ID,
3643
DOMAIN,
3744
EVENT_BUTTON,
3845
MAIN,
@@ -99,6 +106,54 @@ async def _refresh_token() -> str:
99106

100107
client.refresh_token_function = _refresh_token
101108

109+
def _handle_max_connections() -> None:
110+
_LOGGER.debug("We hit the limit of max connections")
111+
hass.config_entries.async_schedule_reload(entry.entry_id)
112+
113+
client.max_connections_reached_callback = _handle_max_connections
114+
115+
def _handle_new_subscription_identifier(identifier: str | None) -> None:
116+
"""Handle a new subscription identifier."""
117+
hass.config_entries.async_update_entry(
118+
entry,
119+
data={
120+
**entry.data,
121+
CONF_SUBSCRIPTION_ID: identifier,
122+
},
123+
)
124+
if identifier is not None:
125+
_LOGGER.debug("Updating subscription ID to %s", identifier)
126+
else:
127+
_LOGGER.debug("Removing subscription ID")
128+
129+
client.new_subscription_id_callback = _handle_new_subscription_identifier
130+
131+
if (old_identifier := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None:
132+
_LOGGER.debug("Trying to delete old subscription %s", old_identifier)
133+
await client.delete_subscription(old_identifier)
134+
135+
_LOGGER.debug("Trying to create a new subscription")
136+
try:
137+
subscription = await client.create_subscription(
138+
entry.data[CONF_LOCATION_ID],
139+
entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID],
140+
)
141+
except SmartThingsSinkError as err:
142+
_LOGGER.debug("Couldn't create a new subscription: %s", err)
143+
raise ConfigEntryNotReady from err
144+
subscription_id = subscription.subscription_id
145+
_handle_new_subscription_identifier(subscription_id)
146+
147+
entry.async_create_background_task(
148+
hass,
149+
client.subscribe(
150+
entry.data[CONF_LOCATION_ID],
151+
entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID],
152+
subscription,
153+
),
154+
"smartthings_socket",
155+
)
156+
102157
device_status: dict[str, FullDevice] = {}
103158
try:
104159
devices = await client.get_devices()
@@ -145,12 +200,12 @@ def handle_button_press(event: DeviceEvent) -> None:
145200
client.add_unspecified_device_event_listener(handle_button_press)
146201
)
147202

148-
entry.async_create_background_task(
149-
hass,
150-
client.subscribe(
151-
entry.data[CONF_LOCATION_ID], entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID]
152-
),
153-
"smartthings_webhook",
203+
async def _handle_shutdown(_: Event) -> None:
204+
"""Handle shutdown."""
205+
await client.delete_subscription(subscription_id)
206+
207+
entry.async_on_unload(
208+
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _handle_shutdown)
154209
)
155210

156211
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -176,6 +231,9 @@ async def async_unload_entry(
176231
hass: HomeAssistant, entry: SmartThingsConfigEntry
177232
) -> bool:
178233
"""Unload a config entry."""
234+
client = entry.runtime_data.client
235+
if (subscription_id := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None:
236+
await client.delete_subscription(subscription_id)
179237
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
180238

181239

homeassistant/components/smartthings/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,5 @@
3333
MAIN = "main"
3434
OLD_DATA = "old_data"
3535

36+
CONF_SUBSCRIPTION_ID = "subscription_id"
3637
EVENT_BUTTON = "smartthings.button"

homeassistant/components/smartthings/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,5 @@
2929
"documentation": "https://www.home-assistant.io/integrations/smartthings",
3030
"iot_class": "cloud_push",
3131
"loggers": ["pysmartthings"],
32-
"requirements": ["pysmartthings==2.7.0"]
32+
"requirements": ["pysmartthings==2.7.2"]
3333
}

requirements_all.txt

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

requirements_test_all.txt

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/components/smartthings/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
DeviceStatus,
1010
LocationResponse,
1111
SceneResponse,
12+
Subscription,
1213
)
1314
import pytest
1415

@@ -78,6 +79,9 @@ def mock_smartthings() -> Generator[AsyncMock]:
7879
client.get_locations.return_value = LocationResponse.from_json(
7980
load_fixture("locations.json", DOMAIN)
8081
).items
82+
client.create_subscription.return_value = Subscription.from_json(
83+
load_fixture("subscription.json", DOMAIN)
84+
)
8185
yield client
8286

8387

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"subscriptionId": "f5768ce8-c9e5-4507-9020-912c0c60e0ab",
3+
"registrationUrl": "https://spigot-regional.api.smartthings.com/filters/f5768ce8-c9e5-4507-9020-912c0c60e0ab/activate?filterRegion=eu-west-1",
4+
"name": "My Home Assistant sub",
5+
"version": 20250122,
6+
"subscriptionFilters": [
7+
{
8+
"type": "LOCATIONIDS",
9+
"value": ["88a3a314-f0c8-40b4-bb44-44ba06c9c42e"],
10+
"eventType": ["DEVICE_EVENT"],
11+
"attribute": null,
12+
"capability": null,
13+
"component": null
14+
}
15+
]
16+
}

tests/components/smartthings/test_config_flow.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
CONF_INSTALLED_APP_ID,
1111
CONF_LOCATION_ID,
1212
CONF_REFRESH_TOKEN,
13+
CONF_SUBSCRIPTION_ID,
1314
DOMAIN,
1415
)
1516
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
@@ -508,6 +509,7 @@ async def test_migration(
508509
"installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324",
509510
},
510511
CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c",
512+
CONF_SUBSCRIPTION_ID: "f5768ce8-c9e5-4507-9020-912c0c60e0ab",
511513
}
512514
assert mock_old_config_entry.unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c"
513515
assert mock_old_config_entry.version == 3

tests/components/smartthings/test_init.py

Lines changed: 178 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,21 @@
22

33
from unittest.mock import AsyncMock
44

5-
from pysmartthings import Attribute, Capability
5+
from pysmartthings import Attribute, Capability, SmartThingsSinkError
6+
from pysmartthings.models import Subscription
67
import pytest
78
from syrupy import SnapshotAssertion
89

910
from homeassistant.components.smartthings import EVENT_BUTTON
10-
from homeassistant.components.smartthings.const import DOMAIN
11+
from homeassistant.components.smartthings.const import CONF_SUBSCRIPTION_ID, DOMAIN
12+
from homeassistant.config_entries import ConfigEntryState
13+
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
1114
from homeassistant.core import Event, HomeAssistant
1215
from homeassistant.helpers import device_registry as dr
1316

1417
from . import setup_integration, trigger_update
1518

16-
from tests.common import MockConfigEntry
19+
from tests.common import MockConfigEntry, load_fixture
1720

1821

1922
async def test_devices(
@@ -63,6 +66,178 @@ def capture_event(event: Event) -> None:
6366
assert events[0] == snapshot
6467

6568

69+
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
70+
async def test_create_subscription(
71+
hass: HomeAssistant,
72+
devices: AsyncMock,
73+
mock_config_entry: MockConfigEntry,
74+
) -> None:
75+
"""Test creating a subscription."""
76+
assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data
77+
78+
await setup_integration(hass, mock_config_entry)
79+
80+
devices.create_subscription.assert_called_once()
81+
82+
assert (
83+
mock_config_entry.data[CONF_SUBSCRIPTION_ID]
84+
== "f5768ce8-c9e5-4507-9020-912c0c60e0ab"
85+
)
86+
87+
devices.subscribe.assert_called_once_with(
88+
"397678e5-9995-4a39-9d9f-ae6ba310236c",
89+
"5aaaa925-2be1-4e40-b257-e4ef59083324",
90+
Subscription.from_json(load_fixture("subscription.json", DOMAIN)),
91+
)
92+
93+
94+
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
95+
async def test_create_subscription_sink_error(
96+
hass: HomeAssistant,
97+
devices: AsyncMock,
98+
mock_config_entry: MockConfigEntry,
99+
snapshot: SnapshotAssertion,
100+
) -> None:
101+
"""Test handling an error when creating a subscription."""
102+
assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data
103+
104+
devices.create_subscription.side_effect = SmartThingsSinkError("Sink error")
105+
106+
await setup_integration(hass, mock_config_entry)
107+
108+
devices.subscribe.assert_not_called()
109+
110+
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
111+
assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data
112+
113+
114+
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
115+
async def test_update_subscription_identifier(
116+
hass: HomeAssistant,
117+
devices: AsyncMock,
118+
mock_config_entry: MockConfigEntry,
119+
) -> None:
120+
"""Test updating the subscription identifier."""
121+
await setup_integration(hass, mock_config_entry)
122+
123+
assert (
124+
mock_config_entry.data[CONF_SUBSCRIPTION_ID]
125+
== "f5768ce8-c9e5-4507-9020-912c0c60e0ab"
126+
)
127+
128+
devices.new_subscription_id_callback("abc")
129+
130+
await hass.async_block_till_done()
131+
132+
assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] == "abc"
133+
134+
135+
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
136+
async def test_stale_subscription_id(
137+
hass: HomeAssistant,
138+
devices: AsyncMock,
139+
mock_config_entry: MockConfigEntry,
140+
) -> None:
141+
"""Test updating the subscription identifier."""
142+
mock_config_entry.add_to_hass(hass)
143+
144+
hass.config_entries.async_update_entry(
145+
mock_config_entry,
146+
data={**mock_config_entry.data, CONF_SUBSCRIPTION_ID: "test"},
147+
)
148+
149+
await hass.config_entries.async_setup(mock_config_entry.entry_id)
150+
await hass.async_block_till_done()
151+
152+
assert (
153+
mock_config_entry.data[CONF_SUBSCRIPTION_ID]
154+
== "f5768ce8-c9e5-4507-9020-912c0c60e0ab"
155+
)
156+
devices.delete_subscription.assert_called_once_with("test")
157+
158+
159+
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
160+
async def test_remove_subscription_identifier(
161+
hass: HomeAssistant,
162+
devices: AsyncMock,
163+
mock_config_entry: MockConfigEntry,
164+
) -> None:
165+
"""Test removing the subscription identifier."""
166+
await setup_integration(hass, mock_config_entry)
167+
168+
assert (
169+
mock_config_entry.data[CONF_SUBSCRIPTION_ID]
170+
== "f5768ce8-c9e5-4507-9020-912c0c60e0ab"
171+
)
172+
173+
devices.new_subscription_id_callback(None)
174+
175+
await hass.async_block_till_done()
176+
177+
assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None
178+
179+
180+
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
181+
async def test_max_connections_handling(
182+
hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry
183+
) -> None:
184+
"""Test handling reaching max connections."""
185+
await setup_integration(hass, mock_config_entry)
186+
187+
assert (
188+
mock_config_entry.data[CONF_SUBSCRIPTION_ID]
189+
== "f5768ce8-c9e5-4507-9020-912c0c60e0ab"
190+
)
191+
192+
devices.create_subscription.side_effect = SmartThingsSinkError("Sink error")
193+
194+
devices.max_connections_reached_callback()
195+
196+
await hass.async_block_till_done()
197+
198+
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
199+
200+
201+
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
202+
async def test_unloading(
203+
hass: HomeAssistant,
204+
devices: AsyncMock,
205+
mock_config_entry: MockConfigEntry,
206+
) -> None:
207+
"""Test unloading the integration."""
208+
await setup_integration(hass, mock_config_entry)
209+
210+
await hass.config_entries.async_unload(mock_config_entry.entry_id)
211+
devices.delete_subscription.assert_called_once_with(
212+
"f5768ce8-c9e5-4507-9020-912c0c60e0ab"
213+
)
214+
# Deleting the subscription automatically deletes the subscription ID
215+
devices.new_subscription_id_callback(None)
216+
217+
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
218+
assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None
219+
220+
221+
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
222+
async def test_shutdown(
223+
hass: HomeAssistant,
224+
devices: AsyncMock,
225+
mock_config_entry: MockConfigEntry,
226+
) -> None:
227+
"""Test shutting down Home Assistant."""
228+
await setup_integration(hass, mock_config_entry)
229+
230+
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
231+
devices.delete_subscription.assert_called_once_with(
232+
"f5768ce8-c9e5-4507-9020-912c0c60e0ab"
233+
)
234+
# Deleting the subscription automatically deletes the subscription ID
235+
devices.new_subscription_id_callback(None)
236+
237+
assert mock_config_entry.state is ConfigEntryState.LOADED
238+
assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None
239+
240+
66241
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
67242
async def test_removing_stale_devices(
68243
hass: HomeAssistant,

0 commit comments

Comments
 (0)