From 9a2f17c2b27b17d5a59e937fcea238982ed75600 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 9 May 2025 16:42:22 +0200 Subject: [PATCH] Refactor Bring! integration to poll activity data at a slower interval (#142621) * Refactor Bring integration to poll activity with slower interval * add test --- homeassistant/components/bring/__init__.py | 12 +- homeassistant/components/bring/coordinator.py | 95 ++++++++- homeassistant/components/bring/diagnostics.py | 11 +- homeassistant/components/bring/entity.py | 10 +- homeassistant/components/bring/event.py | 13 +- homeassistant/components/bring/sensor.py | 3 +- homeassistant/components/bring/todo.py | 3 +- .../bring/snapshots/test_diagnostics.ambr | 188 +++++++++--------- tests/components/bring/test_init.py | 66 ++++++ tests/components/bring/test_util.py | 12 +- 10 files changed, 286 insertions(+), 127 deletions(-) diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index 6dd2d36351c..6c0b34c66f0 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -10,7 +10,12 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import BringConfigEntry, BringDataUpdateCoordinator +from .coordinator import ( + BringActivityCoordinator, + BringConfigEntry, + BringCoordinators, + BringDataUpdateCoordinator, +) PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO] @@ -26,7 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> boo coordinator = BringDataUpdateCoordinator(hass, entry, bring) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + activity_coordinator = BringActivityCoordinator(hass, entry, coordinator) + await activity_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = BringCoordinators(coordinator, activity_coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index e1f9fa45ac8..0a8d980a6aa 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -30,7 +30,15 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator] +type BringConfigEntry = ConfigEntry[BringCoordinators] + + +@dataclass +class BringCoordinators: + """Data class holding coordinators.""" + + data: BringDataUpdateCoordinator + activity: BringActivityCoordinator @dataclass(frozen=True) @@ -39,17 +47,28 @@ class BringData(DataClassORJSONMixin): lst: BringList content: BringItemsResponse + + +@dataclass(frozen=True) +class BringActivityData(DataClassORJSONMixin): + """Coordinator data class.""" + activity: BringActivityResponse users: BringUsersResponse -class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): - """A Bring Data Update Coordinator.""" +class BringBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Bring base coordinator.""" config_entry: BringConfigEntry - user_settings: BringUserSettingsResponse lists: list[BringList] + +class BringDataUpdateCoordinator(BringBaseCoordinator[dict[str, BringData]]): + """A Bring Data Update Coordinator.""" + + user_settings: BringUserSettingsResponse + def __init__( self, hass: HomeAssistant, config_entry: BringConfigEntry, bring: Bring ) -> None: @@ -90,16 +109,19 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): current_lists := {lst.listUuid for lst in self.lists} ): self._purge_deleted_lists() + new_lists = current_lists - self.previous_lists self.previous_lists = current_lists list_dict: dict[str, BringData] = {} for lst in self.lists: - if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx: + if ( + (ctx := set(self.async_contexts())) + and lst.listUuid not in ctx + and lst.listUuid not in new_lists + ): continue try: items = await self.bring.get_list(lst.listUuid) - activity = await self.bring.get_activity(lst.listUuid) - users = await self.bring.get_list_users(lst.listUuid) except BringRequestException as e: raise UpdateFailed( translation_domain=DOMAIN, @@ -111,7 +133,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): translation_key="setup_parse_exception", ) from e else: - list_dict[lst.listUuid] = BringData(lst, items, activity, users) + list_dict[lst.listUuid] = BringData(lst, items) return list_dict @@ -156,3 +178,60 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): device_reg.async_update_device( device.id, remove_config_entry_id=self.config_entry.entry_id ) + + +class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]]): + """A Bring Activity Data Update Coordinator.""" + + user_settings: BringUserSettingsResponse + + def __init__( + self, + hass: HomeAssistant, + config_entry: BringConfigEntry, + coordinator: BringDataUpdateCoordinator, + ) -> None: + """Initialize the Bring Activity data coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(minutes=10), + ) + + self.coordinator = coordinator + self.lists = coordinator.lists + + async def _async_update_data(self) -> dict[str, BringActivityData]: + """Fetch activity data from bring.""" + + list_dict: dict[str, BringActivityData] = {} + for lst in self.lists: + if ( + ctx := set(self.coordinator.async_contexts()) + ) and lst.listUuid not in ctx: + continue + try: + activity = await self.coordinator.bring.get_activity(lst.listUuid) + users = await self.coordinator.bring.get_list_users(lst.listUuid) + except BringAuthException as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={CONF_EMAIL: self.coordinator.bring.mail}, + ) from e + except BringRequestException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_request_exception", + ) from e + except BringParseException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e + else: + list_dict[lst.listUuid] = BringActivityData(activity, users) + + return list_dict diff --git a/homeassistant/components/bring/diagnostics.py b/homeassistant/components/bring/diagnostics.py index e5cafd30ab5..2f5a0cae504 100644 --- a/homeassistant/components/bring/diagnostics.py +++ b/homeassistant/components/bring/diagnostics.py @@ -20,9 +20,12 @@ async def async_get_config_entry_diagnostics( return { "data": { - k: async_redact_data(v.to_dict(), TO_REDACT) - for k, v in config_entry.runtime_data.data.items() + k: v.to_dict() for k, v in config_entry.runtime_data.data.data.items() }, - "lists": [lst.to_dict() for lst in config_entry.runtime_data.lists], - "user_settings": config_entry.runtime_data.user_settings.to_dict(), + "activity": { + k: async_redact_data(v.to_dict(), TO_REDACT) + for k, v in config_entry.runtime_data.activity.data.items() + }, + "lists": [lst.to_dict() for lst in config_entry.runtime_data.data.lists], + "user_settings": config_entry.runtime_data.data.user_settings.to_dict(), } diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index ee90f22beef..1bb49afeb5d 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -8,17 +8,17 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import BringDataUpdateCoordinator +from .coordinator import BringBaseCoordinator -class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): +class BringBaseEntity(CoordinatorEntity[BringBaseCoordinator]): """Bring base entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: BringDataUpdateCoordinator, + coordinator: BringBaseCoordinator, bring_list: BringList, ) -> None: """Initialize the entity.""" @@ -34,5 +34,7 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): }, manufacturer="Bring! Labs AG", model="Bring! Grocery Shopping List", - configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}", + configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}" + if bring_list in self.coordinator.lists + else None, ) diff --git a/homeassistant/components/bring/event.py b/homeassistant/components/bring/event.py index 403856405ce..e9e286dccf0 100644 --- a/homeassistant/components/bring/event.py +++ b/homeassistant/components/bring/event.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BringConfigEntry -from .coordinator import BringDataUpdateCoordinator +from .coordinator import BringActivityCoordinator from .entity import BringBaseEntity PARALLEL_UPDATES = 0 @@ -32,18 +32,18 @@ async def async_setup_entry( """Add event entities.""" nonlocal lists_added - if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added: + if new_lists := {lst.listUuid for lst in coordinator.data.lists} - lists_added: async_add_entities( BringEventEntity( - coordinator, + coordinator.activity, bring_list, ) - for bring_list in coordinator.lists + for bring_list in coordinator.data.lists if bring_list.listUuid in new_lists ) lists_added |= new_lists - coordinator.async_add_listener(add_entities) + coordinator.activity.async_add_listener(add_entities) add_entities() @@ -51,10 +51,11 @@ class BringEventEntity(BringBaseEntity, EventEntity): """An event entity.""" _attr_translation_key = "activities" + coordinator: BringActivityCoordinator def __init__( self, - coordinator: BringDataUpdateCoordinator, + coordinator: BringActivityCoordinator, bring_list: BringList, ) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index 2a09d574607..88399ea26f7 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -88,7 +88,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.data lists_added: set[str] = set() @callback @@ -117,6 +117,7 @@ class BringSensorEntity(BringBaseEntity, SensorEntity): """A sensor entity.""" entity_description: BringSensorEntityDescription + coordinator: BringDataUpdateCoordinator def __init__( self, diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index d1eb9e78341..c72b8c7ca0e 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -44,7 +44,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor from a config entry created in the integrations UI.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.data lists_added: set[str] = set() @callback @@ -88,6 +88,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): | TodoListEntityFeature.DELETE_TODO_ITEM | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) + coordinator: BringDataUpdateCoordinator def __init__( self, coordinator: BringDataUpdateCoordinator, bring_list: BringList diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 3f4c8f5f339..4c8475428e9 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'data': dict({ + 'activity': dict({ 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ 'activity': dict({ 'timeline': list([ @@ -79,58 +79,6 @@ 'timestamp': '2025-01-01T03:09:33.036000+00:00', 'totalEvents': 3, }), - 'content': dict({ - 'items': dict({ - 'purchase': list([ - dict({ - 'attributes': list([ - dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', - }), - ]), - 'itemId': 'Paprika', - 'specification': 'Rot', - 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', - }), - dict({ - 'attributes': list([ - dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', - }), - ]), - 'itemId': 'Pouletbrüstli', - 'specification': 'Bio', - 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', - }), - ]), - 'recently': list([ - dict({ - 'attributes': list([ - ]), - 'itemId': 'Ananas', - 'specification': '', - 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', - }), - ]), - }), - 'status': 'REGISTERED', - 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', - }), - 'lst': dict({ - 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', - 'name': '**REDACTED**', - 'theme': 'ch.publisheria.bring.theme.home', - }), 'users': dict({ 'users': list([ dict({ @@ -246,6 +194,101 @@ 'timestamp': '2025-01-01T03:09:33.036000+00:00', 'totalEvents': 3, }), + 'users': dict({ + 'users': list([ + dict({ + 'country': 'DE', + 'email': '**REDACTED**', + 'language': 'de', + 'name': '**REDACTED**', + 'photoPath': '', + 'plusExpiry': None, + 'plusTryOut': False, + 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': '**REDACTED**', + 'language': 'en', + 'name': '**REDACTED**', + 'photoPath': '', + 'plusExpiry': None, + 'plusTryOut': False, + 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': None, + 'language': 'en', + 'name': None, + 'photoPath': None, + 'plusExpiry': None, + 'plusTryOut': False, + 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', + 'pushEnabled': True, + }), + ]), + }), + }), + }), + 'data': dict({ + 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ + 'content': dict({ + 'items': dict({ + 'purchase': list([ + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', + }), + ]), + }), + 'status': 'REGISTERED', + 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + }), + 'lst': dict({ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'name': 'Baumarkt', + 'theme': 'ch.publisheria.bring.theme.home', + }), + }), + 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5': dict({ 'content': dict({ 'items': dict({ 'purchase': list([ @@ -295,46 +338,9 @@ }), 'lst': dict({ 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', - 'name': '**REDACTED**', + 'name': 'Einkauf', 'theme': 'ch.publisheria.bring.theme.home', }), - 'users': dict({ - 'users': list([ - dict({ - 'country': 'DE', - 'email': '**REDACTED**', - 'language': 'de', - 'name': '**REDACTED**', - 'photoPath': '', - 'plusExpiry': None, - 'plusTryOut': False, - 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', - 'pushEnabled': True, - }), - dict({ - 'country': 'US', - 'email': '**REDACTED**', - 'language': 'en', - 'name': '**REDACTED**', - 'photoPath': '', - 'plusExpiry': None, - 'plusTryOut': False, - 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', - 'pushEnabled': True, - }), - dict({ - 'country': 'US', - 'email': None, - 'language': 'en', - 'name': None, - 'photoPath': None, - 'plusExpiry': None, - 'plusTryOut': False, - 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', - 'pushEnabled': True, - }), - ]), - }), }), }), 'lists': list([ diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index f053f294ef1..7f235ea505c 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -139,6 +139,31 @@ async def test_config_entry_not_ready_udpdate_failed( assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize( + ("exception", "state"), + [ + (BringRequestException, ConfigEntryState.SETUP_RETRY), + (BringParseException, ConfigEntryState.SETUP_RETRY), + (BringAuthException, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_activity_coordinator_errors( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test config entry not ready from update failed in _async_update_data.""" + mock_bring_client.get_activity.side_effect = exception + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is state + + @pytest.mark.parametrize( ("exception", "state"), [ @@ -263,3 +288,44 @@ async def test_create_devices( assert device_registry.async_get_device( {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} ) + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_coordinator_update_intervals( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mock_bring_client: AsyncMock, +) -> None: + """Test the coordinator updates at the specified intervals.""" + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + # fetch 2 lists on first refresh + assert mock_bring_client.load_lists.await_count == 2 + assert mock_bring_client.get_activity.await_count == 2 + + mock_bring_client.load_lists.reset_mock() + mock_bring_client.get_activity.reset_mock() + + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists2.json", DOMAIN) + ) + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # main coordinator refreshes, activity does not + assert mock_bring_client.load_lists.await_count == 1 + assert mock_bring_client.get_activity.await_count == 0 + + mock_bring_client.load_lists.reset_mock() + mock_bring_client.get_activity.reset_mock() + + freezer.tick(timedelta(seconds=510)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # assert activity refreshes after 10min and has up-to-date lists data + assert mock_bring_client.get_activity.await_count == 1 diff --git a/tests/components/bring/test_util.py b/tests/components/bring/test_util.py index 673c4e68a4d..a1d7de2b553 100644 --- a/tests/components/bring/test_util.py +++ b/tests/components/bring/test_util.py @@ -1,12 +1,6 @@ """Test for utility functions of the Bring! integration.""" -from bring_api import ( - BringActivityResponse, - BringItemsResponse, - BringListResponse, - BringUserSettingsResponse, -) -from bring_api.types import BringUsersResponse +from bring_api import BringItemsResponse, BringListResponse, BringUserSettingsResponse import pytest from homeassistant.components.bring.const import DOMAIN @@ -47,10 +41,8 @@ def test_sum_attributes(attribute: str, expected: int) -> None: """Test function sum_attributes.""" items = BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)) lst = BringListResponse.from_json(load_fixture("lists.json", DOMAIN)) - activity = BringActivityResponse.from_json(load_fixture("activity.json", DOMAIN)) - users = BringUsersResponse.from_json(load_fixture("users.json", DOMAIN)) result = sum_attributes( - BringData(lst.lists[0], items, activity, users), + BringData(lst.lists[0], items), attribute, )