diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 0511d285afc..9473d0614e3 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -19,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -39,6 +40,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): config_entry: ConfigEntry user_settings: BringUserSettingsResponse + lists: list[BringList] def __init__(self, hass: HomeAssistant, bring: Bring) -> None: """Initialize the Bring data coordinator.""" @@ -49,10 +51,13 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): update_interval=timedelta(seconds=90), ) self.bring = bring + self.previous_lists: set[str] = set() async def _async_update_data(self) -> dict[str, BringData]: + """Fetch the latest data from bring.""" + try: - lists_response = await self.bring.load_lists() + self.lists = (await self.bring.load_lists()).lists except BringRequestException as e: raise UpdateFailed("Unable to connect and retrieve data from bring") from e except BringParseException as e: @@ -72,8 +77,14 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): ) from exc return self.data + if self.previous_lists - ( + current_lists := {lst.listUuid for lst in self.lists} + ): + self._purge_deleted_lists() + self.previous_lists = current_lists + list_dict: dict[str, BringData] = {} - for lst in lists_response.lists: + for lst in self.lists: if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx: continue try: @@ -95,6 +106,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): try: await self.bring.login() self.user_settings = await self.bring.get_all_user_settings() + self.lists = (await self.bring.load_lists()).lists except BringRequestException as e: raise ConfigEntryNotReady( translation_domain=DOMAIN, @@ -111,3 +123,21 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): translation_key="setup_authentication_exception", translation_placeholders={CONF_EMAIL: self.bring.mail}, ) from e + self._purge_deleted_lists() + + def _purge_deleted_lists(self) -> None: + """Purge device entries of deleted lists.""" + + device_reg = dr.async_get(self.hass) + identifiers = { + (DOMAIN, f"{self.config_entry.unique_id}_{lst.listUuid}") + for lst in self.lists + } + for device in dr.async_entries_for_config_entry( + device_reg, self.config_entry.entry_id + ): + if not set(device.identifiers) & identifiers: + _LOGGER.debug("Removing obsolete device entry %s", device.name) + device_reg.async_update_device( + device.id, remove_config_entry_id=self.config_entry.entry_id + ) diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index 74076d66df9..3de0140d82c 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -2,11 +2,13 @@ from __future__ import annotations +from bring_api.types import BringList + from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import BringData, BringDataUpdateCoordinator +from .coordinator import BringDataUpdateCoordinator class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): @@ -17,20 +19,20 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): def __init__( self, coordinator: BringDataUpdateCoordinator, - bring_list: BringData, + bring_list: BringList, ) -> None: """Initialize the entity.""" - super().__init__(coordinator, bring_list.lst.listUuid) + super().__init__(coordinator, bring_list.listUuid) - self._list_uuid = bring_list.lst.listUuid + self._list_uuid = bring_list.listUuid self.device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - name=bring_list.lst.name, + name=bring_list.name, identifiers={ (DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}") }, manufacturer="Bring! Labs AG", model="Bring! Grocery Shopping List", - configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.data.keys()).index(self._list_uuid)}", + configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}", ) diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml index 1fdb3f13f1b..0b4191d5c61 100644 --- a/homeassistant/components/bring/quality_scale.yaml +++ b/homeassistant/components/bring/quality_scale.yaml @@ -53,7 +53,7 @@ rules: docs-supported-functions: todo docs-troubleshooting: todo docs-use-cases: todo - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -65,7 +65,7 @@ rules: status: exempt comment: | no repairs - stale-devices: todo + stale-devices: done # Platinum async-dependency: done inject-websession: done diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index 02bd0e50788..651307a2eee 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -8,6 +8,7 @@ from enum import StrEnum from bring_api import BringUserSettingsResponse from bring_api.const import BRING_SUPPORTED_LOCALES +from bring_api.types import BringList from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,7 +16,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -90,16 +91,28 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" coordinator = config_entry.runtime_data + lists_added: set[str] = set() - async_add_entities( - BringSensorEntity( - coordinator, - bring_list, - description, - ) - for description in SENSOR_DESCRIPTIONS - for bring_list in coordinator.data.values() - ) + @callback + def add_entities() -> None: + """Add sensor entities.""" + nonlocal lists_added + + if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added: + async_add_entities( + BringSensorEntity( + coordinator, + bring_list, + description, + ) + for description in SENSOR_DESCRIPTIONS + for bring_list in coordinator.lists + if bring_list.listUuid in new_lists + ) + lists_added |= new_lists + + coordinator.async_add_listener(add_entities) + add_entities() class BringSensorEntity(BringBaseEntity, SensorEntity): @@ -110,7 +123,7 @@ class BringSensorEntity(BringBaseEntity, SensorEntity): def __init__( self, coordinator: BringDataUpdateCoordinator, - bring_list: BringData, + bring_list: BringList, entity_description: BringSensorEntityDescription, ) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 7ab60084314..ad4de4196c1 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -12,6 +12,7 @@ from bring_api import ( BringNotificationType, BringRequestException, ) +from bring_api.types import BringList import voluptuous as vol from homeassistant.components.todo import ( @@ -20,7 +21,7 @@ from homeassistant.components.todo import ( TodoListEntity, TodoListEntityFeature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -45,14 +46,23 @@ async def async_setup_entry( ) -> None: """Set up the sensor from a config entry created in the integrations UI.""" coordinator = config_entry.runtime_data + lists_added: set[str] = set() - async_add_entities( - BringTodoListEntity( - coordinator, - bring_list=bring_list, - ) - for bring_list in coordinator.data.values() - ) + @callback + def add_entities() -> None: + """Add or remove todo list entities.""" + nonlocal lists_added + + if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added: + async_add_entities( + BringTodoListEntity(coordinator, bring_list) + for bring_list in coordinator.lists + if bring_list.listUuid in new_lists + ) + lists_added |= new_lists + + coordinator.async_add_listener(add_entities) + add_entities() platform = entity_platform.async_get_current_platform() @@ -81,7 +91,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): ) def __init__( - self, coordinator: BringDataUpdateCoordinator, bring_list: BringData + self, coordinator: BringDataUpdateCoordinator, bring_list: BringList ) -> None: """Initialize the entity.""" super().__init__(coordinator, bring_list) diff --git a/tests/components/bring/fixtures/items.json b/tests/components/bring/fixtures/items.json index eecdbaac8c7..02bfdc9e038 100644 --- a/tests/components/bring/fixtures/items.json +++ b/tests/components/bring/fixtures/items.json @@ -1,5 +1,5 @@ { - "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "status": "REGISTERED", "items": { "purchase": [ diff --git a/tests/components/bring/fixtures/items2.json b/tests/components/bring/fixtures/items2.json new file mode 100644 index 00000000000..c8f2a5e9d02 --- /dev/null +++ b/tests/components/bring/fixtures/items2.json @@ -0,0 +1,46 @@ +{ + "uuid": "b4776778-7f6c-496e-951b-92a35d3db0dd", + "status": "REGISTERED", + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } +} diff --git a/tests/components/bring/fixtures/items_invitation.json b/tests/components/bring/fixtures/items_invitation.json index be3671c359a..6b6623011da 100644 --- a/tests/components/bring/fixtures/items_invitation.json +++ b/tests/components/bring/fixtures/items_invitation.json @@ -1,5 +1,5 @@ { - "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "status": "INVITATION", "items": { "purchase": [ diff --git a/tests/components/bring/fixtures/items_shared.json b/tests/components/bring/fixtures/items_shared.json index 5e381d27ca8..6892e07e4e6 100644 --- a/tests/components/bring/fixtures/items_shared.json +++ b/tests/components/bring/fixtures/items_shared.json @@ -1,5 +1,5 @@ { - "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "status": "SHARED", "items": { "purchase": [ diff --git a/tests/components/bring/fixtures/lists2.json b/tests/components/bring/fixtures/lists2.json new file mode 100644 index 00000000000..511de7bd181 --- /dev/null +++ b/tests/components/bring/fixtures/lists2.json @@ -0,0 +1,9 @@ +{ + "lists": [ + { + "listUuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", + "name": "Einkauf", + "theme": "ch.publisheria.bring.theme.home" + } + ] +} diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 5955ded832a..740f4902fc3 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -47,7 +47,7 @@ ]), }), 'status': 'REGISTERED', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', }), 'lst': dict({ 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', @@ -101,7 +101,7 @@ ]), }), 'status': 'REGISTERED', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + 'uuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', }), 'lst': dict({ 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', diff --git a/tests/components/bring/test_diagnostics.py b/tests/components/bring/test_diagnostics.py index a86de5a0d2d..c4b8defca82 100644 --- a/tests/components/bring/test_diagnostics.py +++ b/tests/components/bring/test_diagnostics.py @@ -1,11 +1,15 @@ """Test for diagnostics platform of the Bring! integration.""" +from unittest.mock import AsyncMock + +from bring_api import BringItemsResponse import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bring.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -16,8 +20,13 @@ async def test_diagnostics( hass_client: ClientSessionGenerator, bring_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, + mock_bring_client: AsyncMock, ) -> None: """Test diagnostics.""" + mock_bring_client.get_list.side_effect = [ + BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)), + BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)), + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index 8c215e024d5..a77c709315f 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -3,7 +3,12 @@ from datetime import timedelta from unittest.mock import AsyncMock -from bring_api import BringAuthException, BringParseException, BringRequestException +from bring_api import ( + BringAuthException, + BringListResponse, + BringParseException, + BringRequestException, +) from freezegun.api import FrozenDateTimeFactory import pytest @@ -16,7 +21,7 @@ from homeassistant.helpers import device_registry as dr from .conftest import UUID -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture async def setup_integration( @@ -115,6 +120,25 @@ async def test_config_entry_not_ready( assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize("exception", [BringRequestException, BringParseException]) +async def test_config_entry_not_ready_udpdate_failed( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + exception: Exception, +) -> None: + """Test config entry not ready from update failed in _async_update_data.""" + mock_bring_client.load_lists.side_effect = [ + mock_bring_client.load_lists.return_value, + 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 ConfigEntryState.SETUP_RETRY + + @pytest.mark.parametrize( ("exception", "state"), [ @@ -133,7 +157,10 @@ async def test_config_entry_not_ready_auth_error( ) -> None: """Test config entry not ready from authentication error.""" - mock_bring_client.load_lists.side_effect = BringAuthException + mock_bring_client.load_lists.side_effect = [ + mock_bring_client.load_lists.return_value, + BringAuthException, + ] mock_bring_client.retrieve_new_access_token.side_effect = exception bring_config_entry.add_to_hass(hass) @@ -170,3 +197,71 @@ async def test_coordinator_skips_deactivated( await hass.async_block_till_done() assert mock_bring_client.get_list.await_count == 1 + + +async def test_purge_devices( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test removing device entry of deleted list.""" + list_uuid = "b4776778-7f6c-496e-951b-92a35d3db0dd" + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) + + 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() + + assert ( + device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) + is None + ) + + +async def test_create_devices( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test create device entry for new lists.""" + list_uuid = "b4776778-7f6c-496e-951b-92a35d3db0dd" + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists2.json", DOMAIN) + ) + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert ( + device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) + is None + ) + + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists.json", DOMAIN) + ) + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) diff --git a/tests/components/bring/test_sensor.py b/tests/components/bring/test_sensor.py index 442fea5a247..f704debcea9 100644 --- a/tests/components/bring/test_sensor.py +++ b/tests/components/bring/test_sensor.py @@ -26,15 +26,19 @@ def sensor_only() -> Generator[None]: yield -@pytest.mark.usefixtures("mock_bring_client") async def test_setup( hass: HomeAssistant, bring_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_bring_client: AsyncMock, ) -> None: """Snapshot test states of sensor platform.""" + mock_bring_client.get_list.side_effect = [ + BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)), + BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)), + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bring/test_todo.py b/tests/components/bring/test_todo.py index 9cc4ae3d888..9df7b892db8 100644 --- a/tests/components/bring/test_todo.py +++ b/tests/components/bring/test_todo.py @@ -4,10 +4,11 @@ from collections.abc import Generator import re from unittest.mock import AsyncMock, patch -from bring_api import BringItemOperation, BringRequestException +from bring_api import BringItemOperation, BringItemsResponse, BringRequestException import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bring.const import DOMAIN from homeassistant.components.todo import ( ATTR_DESCRIPTION, ATTR_ITEM, @@ -21,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, load_fixture, snapshot_platform @pytest.fixture(autouse=True) @@ -40,9 +41,13 @@ async def test_todo( bring_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_bring_client: AsyncMock, ) -> None: """Snapshot test states of todo platform.""" - + mock_bring_client.get_list.side_effect = [ + BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)), + BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)), + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done()