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 <marhje52@gmail.com>
This commit is contained in:
Joost Lekkerkerker 2025-03-11 15:33:32 +01:00 committed by Franck Nijhof
parent b5c7bdd98f
commit f2f653efcf
No known key found for this signature in database
GPG Key ID: D62583BA8AB11CA3
9 changed files with 270 additions and 14 deletions

View File

@ -16,12 +16,18 @@ from pysmartthings import (
Scene,
SmartThings,
SmartThingsAuthenticationFailedError,
SmartThingsSinkError,
Status,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_TOKEN,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -33,6 +39,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from .const import (
CONF_INSTALLED_APP_ID,
CONF_LOCATION_ID,
CONF_SUBSCRIPTION_ID,
DOMAIN,
EVENT_BUTTON,
MAIN,
@ -99,6 +106,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry)
client.refresh_token_function = _refresh_token
def _handle_max_connections() -> None:
_LOGGER.debug("We hit the limit of max connections")
hass.config_entries.async_schedule_reload(entry.entry_id)
client.max_connections_reached_callback = _handle_max_connections
def _handle_new_subscription_identifier(identifier: str | None) -> None:
"""Handle a new subscription identifier."""
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_SUBSCRIPTION_ID: identifier,
},
)
if identifier is not None:
_LOGGER.debug("Updating subscription ID to %s", identifier)
else:
_LOGGER.debug("Removing subscription ID")
client.new_subscription_id_callback = _handle_new_subscription_identifier
if (old_identifier := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None:
_LOGGER.debug("Trying to delete old subscription %s", old_identifier)
await client.delete_subscription(old_identifier)
_LOGGER.debug("Trying to create a new subscription")
try:
subscription = await client.create_subscription(
entry.data[CONF_LOCATION_ID],
entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID],
)
except SmartThingsSinkError as err:
_LOGGER.debug("Couldn't create a new subscription: %s", err)
raise ConfigEntryNotReady from err
subscription_id = subscription.subscription_id
_handle_new_subscription_identifier(subscription_id)
entry.async_create_background_task(
hass,
client.subscribe(
entry.data[CONF_LOCATION_ID],
entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID],
subscription,
),
"smartthings_socket",
)
device_status: dict[str, FullDevice] = {}
try:
devices = await client.get_devices()
@ -145,12 +200,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry)
client.add_unspecified_device_event_listener(handle_button_press)
)
entry.async_create_background_task(
hass,
client.subscribe(
entry.data[CONF_LOCATION_ID], entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID]
),
"smartthings_webhook",
async def _handle_shutdown(_: Event) -> None:
"""Handle shutdown."""
await client.delete_subscription(subscription_id)
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _handle_shutdown)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@ -176,6 +231,9 @@ async def async_unload_entry(
hass: HomeAssistant, entry: SmartThingsConfigEntry
) -> bool:
"""Unload a config entry."""
client = entry.runtime_data.client
if (subscription_id := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None:
await client.delete_subscription(subscription_id)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -33,4 +33,5 @@ CONF_REFRESH_TOKEN = "refresh_token"
MAIN = "main"
OLD_DATA = "old_data"
CONF_SUBSCRIPTION_ID = "subscription_id"
EVENT_BUTTON = "smartthings.button"

View File

@ -29,5 +29,5 @@
"documentation": "https://www.home-assistant.io/integrations/smartthings",
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"requirements": ["pysmartthings==2.7.0"]
"requirements": ["pysmartthings==2.7.2"]
}

2
requirements_all.txt generated
View File

@ -2310,7 +2310,7 @@ pysma==0.7.5
pysmappee==0.2.29
# homeassistant.components.smartthings
pysmartthings==2.7.0
pysmartthings==2.7.2
# homeassistant.components.smarty
pysmarty2==0.10.2

View File

@ -1882,7 +1882,7 @@ pysma==0.7.5
pysmappee==0.2.29
# homeassistant.components.smartthings
pysmartthings==2.7.0
pysmartthings==2.7.2
# homeassistant.components.smarty
pysmarty2==0.10.2

View File

@ -9,6 +9,7 @@ from pysmartthings.models import (
DeviceStatus,
LocationResponse,
SceneResponse,
Subscription,
)
import pytest
@ -78,6 +79,9 @@ def mock_smartthings() -> Generator[AsyncMock]:
client.get_locations.return_value = LocationResponse.from_json(
load_fixture("locations.json", DOMAIN)
).items
client.create_subscription.return_value = Subscription.from_json(
load_fixture("subscription.json", DOMAIN)
)
yield client

View File

@ -0,0 +1,16 @@
{
"subscriptionId": "f5768ce8-c9e5-4507-9020-912c0c60e0ab",
"registrationUrl": "https://spigot-regional.api.smartthings.com/filters/f5768ce8-c9e5-4507-9020-912c0c60e0ab/activate?filterRegion=eu-west-1",
"name": "My Home Assistant sub",
"version": 20250122,
"subscriptionFilters": [
{
"type": "LOCATIONIDS",
"value": ["88a3a314-f0c8-40b4-bb44-44ba06c9c42e"],
"eventType": ["DEVICE_EVENT"],
"attribute": null,
"capability": null,
"component": null
}
]
}

View File

@ -10,6 +10,7 @@ from homeassistant.components.smartthings.const import (
CONF_INSTALLED_APP_ID,
CONF_LOCATION_ID,
CONF_REFRESH_TOKEN,
CONF_SUBSCRIPTION_ID,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
@ -508,6 +509,7 @@ async def test_migration(
"installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324",
},
CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c",
CONF_SUBSCRIPTION_ID: "f5768ce8-c9e5-4507-9020-912c0c60e0ab",
}
assert mock_old_config_entry.unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c"
assert mock_old_config_entry.version == 3

View File

@ -2,18 +2,21 @@
from unittest.mock import AsyncMock
from pysmartthings import Attribute, Capability
from pysmartthings import Attribute, Capability, SmartThingsSinkError
from pysmartthings.models import Subscription
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.smartthings import EVENT_BUTTON
from homeassistant.components.smartthings.const import DOMAIN
from homeassistant.components.smartthings.const import CONF_SUBSCRIPTION_ID, DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import setup_integration, trigger_update
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, load_fixture
async def test_devices(
@ -63,6 +66,178 @@ async def test_button_event(
assert events[0] == snapshot
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
async def test_create_subscription(
hass: HomeAssistant,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test creating a subscription."""
assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data
await setup_integration(hass, mock_config_entry)
devices.create_subscription.assert_called_once()
assert (
mock_config_entry.data[CONF_SUBSCRIPTION_ID]
== "f5768ce8-c9e5-4507-9020-912c0c60e0ab"
)
devices.subscribe.assert_called_once_with(
"397678e5-9995-4a39-9d9f-ae6ba310236c",
"5aaaa925-2be1-4e40-b257-e4ef59083324",
Subscription.from_json(load_fixture("subscription.json", DOMAIN)),
)
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
async def test_create_subscription_sink_error(
hass: HomeAssistant,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test handling an error when creating a subscription."""
assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data
devices.create_subscription.side_effect = SmartThingsSinkError("Sink error")
await setup_integration(hass, mock_config_entry)
devices.subscribe.assert_not_called()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
async def test_update_subscription_identifier(
hass: HomeAssistant,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test updating the subscription identifier."""
await setup_integration(hass, mock_config_entry)
assert (
mock_config_entry.data[CONF_SUBSCRIPTION_ID]
== "f5768ce8-c9e5-4507-9020-912c0c60e0ab"
)
devices.new_subscription_id_callback("abc")
await hass.async_block_till_done()
assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] == "abc"
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
async def test_stale_subscription_id(
hass: HomeAssistant,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test updating the subscription identifier."""
mock_config_entry.add_to_hass(hass)
hass.config_entries.async_update_entry(
mock_config_entry,
data={**mock_config_entry.data, CONF_SUBSCRIPTION_ID: "test"},
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert (
mock_config_entry.data[CONF_SUBSCRIPTION_ID]
== "f5768ce8-c9e5-4507-9020-912c0c60e0ab"
)
devices.delete_subscription.assert_called_once_with("test")
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
async def test_remove_subscription_identifier(
hass: HomeAssistant,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test removing the subscription identifier."""
await setup_integration(hass, mock_config_entry)
assert (
mock_config_entry.data[CONF_SUBSCRIPTION_ID]
== "f5768ce8-c9e5-4507-9020-912c0c60e0ab"
)
devices.new_subscription_id_callback(None)
await hass.async_block_till_done()
assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
async def test_max_connections_handling(
hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test handling reaching max connections."""
await setup_integration(hass, mock_config_entry)
assert (
mock_config_entry.data[CONF_SUBSCRIPTION_ID]
== "f5768ce8-c9e5-4507-9020-912c0c60e0ab"
)
devices.create_subscription.side_effect = SmartThingsSinkError("Sink error")
devices.max_connections_reached_callback()
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
async def test_unloading(
hass: HomeAssistant,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test unloading the integration."""
await setup_integration(hass, mock_config_entry)
await hass.config_entries.async_unload(mock_config_entry.entry_id)
devices.delete_subscription.assert_called_once_with(
"f5768ce8-c9e5-4507-9020-912c0c60e0ab"
)
# Deleting the subscription automatically deletes the subscription ID
devices.new_subscription_id_callback(None)
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
async def test_shutdown(
hass: HomeAssistant,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test shutting down Home Assistant."""
await setup_integration(hass, mock_config_entry)
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
devices.delete_subscription.assert_called_once_with(
"f5768ce8-c9e5-4507-9020-912c0c60e0ab"
)
# Deleting the subscription automatically deletes the subscription ID
devices.new_subscription_id_callback(None)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
async def test_removing_stale_devices(
hass: HomeAssistant,