Refactor Bring! integration to poll activity data at a slower interval (#142621)

* Refactor Bring integration to poll activity with slower interval

* add test
This commit is contained in:
Manu 2025-05-09 16:42:22 +02:00 committed by GitHub
parent 4cecb6c851
commit 9a2f17c2b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 286 additions and 127 deletions

View File

@ -10,7 +10,12 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession 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] 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) coordinator = BringDataUpdateCoordinator(hass, entry, bring)
await coordinator.async_config_entry_first_refresh() 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) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@ -30,7 +30,15 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _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) @dataclass(frozen=True)
@ -39,17 +47,28 @@ class BringData(DataClassORJSONMixin):
lst: BringList lst: BringList
content: BringItemsResponse content: BringItemsResponse
@dataclass(frozen=True)
class BringActivityData(DataClassORJSONMixin):
"""Coordinator data class."""
activity: BringActivityResponse activity: BringActivityResponse
users: BringUsersResponse users: BringUsersResponse
class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): class BringBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
"""A Bring Data Update Coordinator.""" """Bring base coordinator."""
config_entry: BringConfigEntry config_entry: BringConfigEntry
user_settings: BringUserSettingsResponse
lists: list[BringList] lists: list[BringList]
class BringDataUpdateCoordinator(BringBaseCoordinator[dict[str, BringData]]):
"""A Bring Data Update Coordinator."""
user_settings: BringUserSettingsResponse
def __init__( def __init__(
self, hass: HomeAssistant, config_entry: BringConfigEntry, bring: Bring self, hass: HomeAssistant, config_entry: BringConfigEntry, bring: Bring
) -> None: ) -> None:
@ -90,16 +109,19 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
current_lists := {lst.listUuid for lst in self.lists} current_lists := {lst.listUuid for lst in self.lists}
): ):
self._purge_deleted_lists() self._purge_deleted_lists()
new_lists = current_lists - self.previous_lists
self.previous_lists = current_lists self.previous_lists = current_lists
list_dict: dict[str, BringData] = {} list_dict: dict[str, BringData] = {}
for lst in self.lists: 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 continue
try: try:
items = await self.bring.get_list(lst.listUuid) 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: except BringRequestException as e:
raise UpdateFailed( raise UpdateFailed(
translation_domain=DOMAIN, translation_domain=DOMAIN,
@ -111,7 +133,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
translation_key="setup_parse_exception", translation_key="setup_parse_exception",
) from e ) from e
else: else:
list_dict[lst.listUuid] = BringData(lst, items, activity, users) list_dict[lst.listUuid] = BringData(lst, items)
return list_dict return list_dict
@ -156,3 +178,60 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
device_reg.async_update_device( device_reg.async_update_device(
device.id, remove_config_entry_id=self.config_entry.entry_id 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

View File

@ -20,9 +20,12 @@ async def async_get_config_entry_diagnostics(
return { return {
"data": { "data": {
k: async_redact_data(v.to_dict(), TO_REDACT) k: v.to_dict() for k, v in config_entry.runtime_data.data.data.items()
for k, v in config_entry.runtime_data.data.items()
}, },
"lists": [lst.to_dict() for lst in config_entry.runtime_data.lists], "activity": {
"user_settings": config_entry.runtime_data.user_settings.to_dict(), 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(),
} }

View File

@ -8,17 +8,17 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import BringDataUpdateCoordinator from .coordinator import BringBaseCoordinator
class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): class BringBaseEntity(CoordinatorEntity[BringBaseCoordinator]):
"""Bring base entity.""" """Bring base entity."""
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__( def __init__(
self, self,
coordinator: BringDataUpdateCoordinator, coordinator: BringBaseCoordinator,
bring_list: BringList, bring_list: BringList,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
@ -34,5 +34,7 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
}, },
manufacturer="Bring! Labs AG", manufacturer="Bring! Labs AG",
model="Bring! Grocery Shopping List", 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,
) )

View File

@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BringConfigEntry from . import BringConfigEntry
from .coordinator import BringDataUpdateCoordinator from .coordinator import BringActivityCoordinator
from .entity import BringBaseEntity from .entity import BringBaseEntity
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@ -32,18 +32,18 @@ async def async_setup_entry(
"""Add event entities.""" """Add event entities."""
nonlocal lists_added 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( async_add_entities(
BringEventEntity( BringEventEntity(
coordinator, coordinator.activity,
bring_list, bring_list,
) )
for bring_list in coordinator.lists for bring_list in coordinator.data.lists
if bring_list.listUuid in new_lists if bring_list.listUuid in new_lists
) )
lists_added |= new_lists lists_added |= new_lists
coordinator.async_add_listener(add_entities) coordinator.activity.async_add_listener(add_entities)
add_entities() add_entities()
@ -51,10 +51,11 @@ class BringEventEntity(BringBaseEntity, EventEntity):
"""An event entity.""" """An event entity."""
_attr_translation_key = "activities" _attr_translation_key = "activities"
coordinator: BringActivityCoordinator
def __init__( def __init__(
self, self,
coordinator: BringDataUpdateCoordinator, coordinator: BringActivityCoordinator,
bring_list: BringList, bring_list: BringList,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""

View File

@ -88,7 +88,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the sensor platform.""" """Set up the sensor platform."""
coordinator = config_entry.runtime_data coordinator = config_entry.runtime_data.data
lists_added: set[str] = set() lists_added: set[str] = set()
@callback @callback
@ -117,6 +117,7 @@ class BringSensorEntity(BringBaseEntity, SensorEntity):
"""A sensor entity.""" """A sensor entity."""
entity_description: BringSensorEntityDescription entity_description: BringSensorEntityDescription
coordinator: BringDataUpdateCoordinator
def __init__( def __init__(
self, self,

View File

@ -44,7 +44,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the sensor from a config entry created in the integrations UI.""" """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() lists_added: set[str] = set()
@callback @callback
@ -88,6 +88,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
| TodoListEntityFeature.DELETE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM
| TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
) )
coordinator: BringDataUpdateCoordinator
def __init__( def __init__(
self, coordinator: BringDataUpdateCoordinator, bring_list: BringList self, coordinator: BringDataUpdateCoordinator, bring_list: BringList

View File

@ -1,7 +1,7 @@
# serializer version: 1 # serializer version: 1
# name: test_diagnostics # name: test_diagnostics
dict({ dict({
'data': dict({ 'activity': dict({
'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({
'activity': dict({ 'activity': dict({
'timeline': list([ 'timeline': list([
@ -79,58 +79,6 @@
'timestamp': '2025-01-01T03:09:33.036000+00:00', 'timestamp': '2025-01-01T03:09:33.036000+00:00',
'totalEvents': 3, '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': dict({
'users': list([ 'users': list([
dict({ dict({
@ -246,6 +194,101 @@
'timestamp': '2025-01-01T03:09:33.036000+00:00', 'timestamp': '2025-01-01T03:09:33.036000+00:00',
'totalEvents': 3, '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({ 'content': dict({
'items': dict({ 'items': dict({
'purchase': list([ 'purchase': list([
@ -295,46 +338,9 @@
}), }),
'lst': dict({ 'lst': dict({
'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5',
'name': '**REDACTED**', 'name': 'Einkauf',
'theme': 'ch.publisheria.bring.theme.home', '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([ 'lists': list([

View File

@ -139,6 +139,31 @@ async def test_config_entry_not_ready_udpdate_failed(
assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY 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( @pytest.mark.parametrize(
("exception", "state"), ("exception", "state"),
[ [
@ -263,3 +288,44 @@ async def test_create_devices(
assert device_registry.async_get_device( assert device_registry.async_get_device(
{(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} {(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

View File

@ -1,12 +1,6 @@
"""Test for utility functions of the Bring! integration.""" """Test for utility functions of the Bring! integration."""
from bring_api import ( from bring_api import BringItemsResponse, BringListResponse, BringUserSettingsResponse
BringActivityResponse,
BringItemsResponse,
BringListResponse,
BringUserSettingsResponse,
)
from bring_api.types import BringUsersResponse
import pytest import pytest
from homeassistant.components.bring.const import DOMAIN from homeassistant.components.bring.const import DOMAIN
@ -47,10 +41,8 @@ def test_sum_attributes(attribute: str, expected: int) -> None:
"""Test function sum_attributes.""" """Test function sum_attributes."""
items = BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)) items = BringItemsResponse.from_json(load_fixture("items.json", DOMAIN))
lst = BringListResponse.from_json(load_fixture("lists.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( result = sum_attributes(
BringData(lst.lists[0], items, activity, users), BringData(lst.lists[0], items),
attribute, attribute,
) )