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, Scene,
SmartThings, SmartThings,
SmartThingsAuthenticationFailedError, SmartThingsAuthenticationFailedError,
SmartThingsSinkError,
Status, Status,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform from homeassistant.const import (
from homeassistant.core import HomeAssistant CONF_ACCESS_TOKEN,
CONF_TOKEN,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -33,6 +39,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from .const import ( from .const import (
CONF_INSTALLED_APP_ID, CONF_INSTALLED_APP_ID,
CONF_LOCATION_ID, CONF_LOCATION_ID,
CONF_SUBSCRIPTION_ID,
DOMAIN, DOMAIN,
EVENT_BUTTON, EVENT_BUTTON,
MAIN, MAIN,
@ -99,6 +106,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry)
client.refresh_token_function = _refresh_token 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] = {} device_status: dict[str, FullDevice] = {}
try: try:
devices = await client.get_devices() 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) client.add_unspecified_device_event_listener(handle_button_press)
) )
entry.async_create_background_task( async def _handle_shutdown(_: Event) -> None:
hass, """Handle shutdown."""
client.subscribe( await client.delete_subscription(subscription_id)
entry.data[CONF_LOCATION_ID], entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID]
), entry.async_on_unload(
"smartthings_webhook", hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _handle_shutdown)
) )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@ -176,6 +231,9 @@ async def async_unload_entry(
hass: HomeAssistant, entry: SmartThingsConfigEntry hass: HomeAssistant, entry: SmartThingsConfigEntry
) -> bool: ) -> bool:
"""Unload a config entry.""" """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) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

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

View File

@ -29,5 +29,5 @@
"documentation": "https://www.home-assistant.io/integrations/smartthings", "documentation": "https://www.home-assistant.io/integrations/smartthings",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pysmartthings"], "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 pysmappee==0.2.29
# homeassistant.components.smartthings # homeassistant.components.smartthings
pysmartthings==2.7.0 pysmartthings==2.7.2
# homeassistant.components.smarty # homeassistant.components.smarty
pysmarty2==0.10.2 pysmarty2==0.10.2

View File

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

View File

@ -9,6 +9,7 @@ from pysmartthings.models import (
DeviceStatus, DeviceStatus,
LocationResponse, LocationResponse,
SceneResponse, SceneResponse,
Subscription,
) )
import pytest import pytest
@ -78,6 +79,9 @@ def mock_smartthings() -> Generator[AsyncMock]:
client.get_locations.return_value = LocationResponse.from_json( client.get_locations.return_value = LocationResponse.from_json(
load_fixture("locations.json", DOMAIN) load_fixture("locations.json", DOMAIN)
).items ).items
client.create_subscription.return_value = Subscription.from_json(
load_fixture("subscription.json", DOMAIN)
)
yield client 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_INSTALLED_APP_ID,
CONF_LOCATION_ID, CONF_LOCATION_ID,
CONF_REFRESH_TOKEN, CONF_REFRESH_TOKEN,
CONF_SUBSCRIPTION_ID,
DOMAIN, DOMAIN,
) )
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
@ -508,6 +509,7 @@ async def test_migration(
"installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324",
}, },
CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", 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.unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c"
assert mock_old_config_entry.version == 3 assert mock_old_config_entry.version == 3

View File

@ -2,18 +2,21 @@
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from pysmartthings import Attribute, Capability from pysmartthings import Attribute, Capability, SmartThingsSinkError
from pysmartthings.models import Subscription
import pytest import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
from homeassistant.components.smartthings import EVENT_BUTTON 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.core import Event, HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from . import setup_integration, trigger_update from . import setup_integration, trigger_update
from tests.common import MockConfigEntry from tests.common import MockConfigEntry, load_fixture
async def test_devices( async def test_devices(
@ -63,6 +66,178 @@ async def test_button_event(
assert events[0] == snapshot 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"]) @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
async def test_removing_stale_devices( async def test_removing_stale_devices(
hass: HomeAssistant, hass: HomeAssistant,