Create/delete lists at runtime in Bring integration (#130098)

pull/137006/head
Manu 2025-01-31 13:23:44 +01:00 committed by GitHub
parent 8eb9cc0e8e
commit 0773e37dab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 266 additions and 43 deletions

View File

@ -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
)

View File

@ -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)}",
)

View File

@ -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

View File

@ -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."""

View File

@ -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)

View File

@ -1,5 +1,5 @@
{
"uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f",
"uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5",
"status": "REGISTERED",
"items": {
"purchase": [

View File

@ -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": []
}
]
}
}

View File

@ -1,5 +1,5 @@
{
"uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f",
"uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5",
"status": "INVITATION",
"items": {
"purchase": [

View File

@ -1,5 +1,5 @@
{
"uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f",
"uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5",
"status": "SHARED",
"items": {
"purchase": [

View File

@ -0,0 +1,9 @@
{
"lists": [
{
"listUuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5",
"name": "Einkauf",
"theme": "ch.publisheria.bring.theme.home"
}
]
}

View File

@ -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',

View File

@ -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()

View File

@ -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}")}
)

View File

@ -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()

View File

@ -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()