Add bring integration (#108027)

* add bring integration

* fix typings and remove from strictly typed
- wait for python-bring-api to be ready for strictly typed

* make entity unique to user and list
- before it was only list, therefore the same list imported by two users would have failed

* simplify bring attribute

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* cleanup and code simplification

* remove empty fields in manifest

* __init__.py aktualisieren

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* __init__.py aktualisieren

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* strings.json aktualisieren

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* streamline async calls

* use coordinator refresh

* fix order in update call and simplify bring list

* simplify the config_flow

* Update homeassistant/components/bring/manifest.json

Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com>

* add unit testing for __init__.py

* cleanup comments

* use dict instead of list

* Update homeassistant/components/bring/todo.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* clean up

* update attribute name

* update more attribute name

* improve unit tests

- remove patch and use mock in conftest

* clean up tests even more

* more unit test inprovements

* remove optional type

* minor unit test cleanup

* Update .coveragerc

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com>
This commit is contained in:
Cyrill Raccaud 2024-01-29 15:08:11 +01:00 committed by GitHub
parent d631cad07f
commit f82fb63dce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 664 additions and 0 deletions

View File

@ -150,6 +150,8 @@ omit =
homeassistant/components/braviatv/coordinator.py
homeassistant/components/braviatv/media_player.py
homeassistant/components/braviatv/remote.py
homeassistant/components/bring/coordinator.py
homeassistant/components/bring/todo.py
homeassistant/components/broadlink/climate.py
homeassistant/components/broadlink/light.py
homeassistant/components/broadlink/remote.py

View File

@ -180,6 +180,8 @@ build.json @home-assistant/supervisor
/tests/components/bosch_shc/ @tschamm
/homeassistant/components/braviatv/ @bieniu @Drafteed
/tests/components/braviatv/ @bieniu @Drafteed
/homeassistant/components/bring/ @miaucl @tr4nt0r
/tests/components/bring/ @miaucl @tr4nt0r
/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
/homeassistant/components/brother/ @bieniu

View File

@ -0,0 +1,76 @@
"""The Bring! integration."""
from __future__ import annotations
import logging
from python_bring_api.bring import Bring
from python_bring_api.exceptions import (
BringAuthException,
BringParseException,
BringRequestException,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from .const import DOMAIN
from .coordinator import BringDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.TODO]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Bring! from a config entry."""
email = entry.data[CONF_EMAIL]
password = entry.data[CONF_PASSWORD]
bring = Bring(email, password)
def login_and_load_lists() -> None:
bring.login()
bring.loadLists()
try:
await hass.async_add_executor_job(login_and_load_lists)
except BringRequestException as e:
raise ConfigEntryNotReady(
f"Timeout while connecting for email '{email}'"
) from e
except BringAuthException as e:
_LOGGER.error(
"Authentication failed for '%s', check your email and password",
email,
)
raise ConfigEntryError(
f"Authentication failed for '{email}', check your email and password"
) from e
except BringParseException as e:
_LOGGER.error(
"Failed to parse request '%s', check your email and password",
email,
)
raise ConfigEntryNotReady(
"Failed to parse response request from server, try again later"
) from e
coordinator = BringDataUpdateCoordinator(hass, bring)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -0,0 +1,75 @@
"""Config flow for Bring! integration."""
from __future__ import annotations
import logging
from typing import Any
from python_bring_api.bring import Bring
from python_bring_api.exceptions import BringAuthException, BringRequestException
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): TextSelector(
TextSelectorConfig(
type=TextSelectorType.EMAIL,
),
),
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
),
),
}
)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Bring!."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
bring = Bring(user_input[CONF_EMAIL], user_input[CONF_PASSWORD])
def login_and_load_lists() -> None:
bring.login()
bring.loadLists()
try:
await self.hass.async_add_executor_job(login_and_load_lists)
except BringRequestException:
errors["base"] = "cannot_connect"
except BringAuthException:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(bring.uuid)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_EMAIL], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@ -0,0 +1,3 @@
"""Constants for the Bring! integration."""
DOMAIN = "bring"

View File

@ -0,0 +1,66 @@
"""DataUpdateCoordinator for the Bring! integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from python_bring_api.bring import Bring
from python_bring_api.exceptions import BringParseException, BringRequestException
from python_bring_api.types import BringItemsResponse, BringList
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class BringData(BringList):
"""Coordinator data class."""
items: list[BringItemsResponse]
class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
"""A Bring Data Update Coordinator."""
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, bring: Bring) -> None:
"""Initialize the Bring data coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=90),
)
self.bring = bring
async def _async_update_data(self) -> dict[str, BringData]:
try:
lists_response = await self.hass.async_add_executor_job(
self.bring.loadLists
)
except BringRequestException as e:
raise UpdateFailed("Unable to connect and retrieve data from bring") from e
except BringParseException as e:
raise UpdateFailed("Unable to parse response from bring") from e
list_dict = {}
for lst in lists_response["lists"]:
try:
items = await self.hass.async_add_executor_job(
self.bring.getItems, lst["listUuid"]
)
except BringRequestException as e:
raise UpdateFailed(
"Unable to connect and retrieve data from bring"
) from e
except BringParseException as e:
raise UpdateFailed("Unable to parse response from bring") from e
lst["items"] = items["purchase"]
list_dict[lst["listUuid"]] = lst
return list_dict

View File

@ -0,0 +1,10 @@
{
"domain": "bring",
"name": "Bring!",
"codeowners": ["@miaucl", "@tr4nt0r"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bring",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["python-bring-api==2.0.0"]
}

View File

@ -0,0 +1,20 @@
{
"config": {
"step": {
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
}
}
}

View File

@ -0,0 +1,173 @@
"""Todo platform for the Bring! integration."""
from __future__ import annotations
from typing import TYPE_CHECKING
from python_bring_api.exceptions import BringRequestException
from homeassistant.components.todo import (
TodoItem,
TodoItemStatus,
TodoListEntity,
TodoListEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import BringData, BringDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the sensor from a config entry created in the integrations UI."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
unique_id = config_entry.unique_id
if TYPE_CHECKING:
assert unique_id
async_add_entities(
BringTodoListEntity(
coordinator,
bring_list=bring_list,
unique_id=unique_id,
)
for bring_list in coordinator.data.values()
)
class BringTodoListEntity(
CoordinatorEntity[BringDataUpdateCoordinator], TodoListEntity
):
"""A To-do List representation of the Bring! Shopping List."""
_attr_icon = "mdi:cart"
_attr_has_entity_name = True
_attr_supported_features = (
TodoListEntityFeature.CREATE_TODO_ITEM
| TodoListEntityFeature.UPDATE_TODO_ITEM
| TodoListEntityFeature.DELETE_TODO_ITEM
| TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
)
def __init__(
self,
coordinator: BringDataUpdateCoordinator,
bring_list: BringData,
unique_id: str,
) -> None:
"""Initialize BringTodoListEntity."""
super().__init__(coordinator)
self._list_uuid = bring_list["listUuid"]
self._attr_name = bring_list["name"]
self._attr_unique_id = f"{unique_id}_{self._list_uuid}"
@property
def todo_items(self) -> list[TodoItem]:
"""Return the todo items."""
return [
TodoItem(
uid=item["name"],
summary=item["name"],
description=item["specification"] or "",
status=TodoItemStatus.NEEDS_ACTION,
)
for item in self.bring_list["items"]
]
@property
def bring_list(self) -> BringData:
"""Return the bring list."""
return self.coordinator.data[self._list_uuid]
async def async_create_todo_item(self, item: TodoItem) -> None:
"""Add an item to the To-do list."""
try:
await self.hass.async_add_executor_job(
self.coordinator.bring.saveItem,
self.bring_list["listUuid"],
item.summary,
item.description or "",
)
except BringRequestException as e:
raise HomeAssistantError("Unable to save todo item for bring") from e
await self.coordinator.async_refresh()
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update an item to the To-do list.
Bring has an internal 'recent' list which we want to use instead of a todo list
status, therefore completed todo list items will directly be deleted
This results in following behaviour:
- Completed items will move to the "completed" section in home assistant todo
list and get deleted in bring, which will remove them from the home
assistant todo list completely after a short delay
- Bring items do not have unique identifiers and are using the
name/summery/title. Therefore the name is not to be changed! Should a name
be changed anyway, a new item will be created instead and no update for
this item is performed and on the next cloud pull update, it will get
cleared
"""
bring_list = self.bring_list
if TYPE_CHECKING:
assert item.uid
if item.status == TodoItemStatus.COMPLETED:
await self.hass.async_add_executor_job(
self.coordinator.bring.removeItem,
bring_list["listUuid"],
item.uid,
)
elif item.summary == item.uid:
try:
await self.hass.async_add_executor_job(
self.coordinator.bring.updateItem,
bring_list["listUuid"],
item.uid,
item.description or "",
)
except BringRequestException as e:
raise HomeAssistantError("Unable to update todo item for bring") from e
else:
try:
await self.hass.async_add_executor_job(
self.coordinator.bring.removeItem,
bring_list["listUuid"],
item.uid,
)
await self.hass.async_add_executor_job(
self.coordinator.bring.saveItem,
bring_list["listUuid"],
item.summary,
item.description or "",
)
except BringRequestException as e:
raise HomeAssistantError("Unable to replace todo item for bring") from e
await self.coordinator.async_refresh()
async def async_delete_todo_items(self, uids: list[str]) -> None:
"""Delete an item from the To-do list."""
for uid in uids:
try:
await self.hass.async_add_executor_job(
self.coordinator.bring.removeItem, self.bring_list["listUuid"], uid
)
except BringRequestException as e:
raise HomeAssistantError("Unable to delete todo item for bring") from e
await self.coordinator.async_refresh()

View File

@ -77,6 +77,7 @@ FLOWS = {
"bond",
"bosch_shc",
"braviatv",
"bring",
"broadlink",
"brother",
"brottsplatskartan",

View File

@ -732,6 +732,12 @@
"integration_type": "virtual",
"supported_by": "motion_blinds"
},
"bring": {
"name": "Bring!",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},
"broadlink": {
"name": "Broadlink",
"integration_type": "hub",

View File

@ -2177,6 +2177,9 @@ python-awair==0.2.4
# homeassistant.components.blockchain
python-blockchain-api==0.0.2
# homeassistant.components.bring
python-bring-api==2.0.0
# homeassistant.components.bsblan
python-bsblan==0.5.18

View File

@ -1677,6 +1677,9 @@ python-MotionMount==0.3.1
# homeassistant.components.awair
python-awair==0.2.4
# homeassistant.components.bring
python-bring-api==2.0.0
# homeassistant.components.bsblan
python-bsblan==0.5.18

View File

@ -0,0 +1 @@
"""Tests for the Bring! integration."""

View File

@ -0,0 +1,49 @@
"""Common fixtures for the Bring! tests."""
from collections.abc import Generator
from unittest.mock import Mock, patch
import pytest
from homeassistant.components.bring import DOMAIN
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from tests.common import MockConfigEntry
EMAIL = "test-email"
PASSWORD = "test-password"
UUID = "00000000-00000000-00000000-00000000"
@pytest.fixture
def mock_setup_entry() -> Generator[Mock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.bring.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_bring_client() -> Generator[Mock, None, None]:
"""Mock a Bring client."""
with patch(
"homeassistant.components.bring.Bring",
autospec=True,
) as mock_client, patch(
"homeassistant.components.bring.config_flow.Bring",
new=mock_client,
):
client = mock_client.return_value
client.uuid = UUID
client.login.return_value = True
client.loadLists.return_value = {"lists": []}
yield client
@pytest.fixture(name="bring_config_entry")
def mock_bring_config_entry() -> MockConfigEntry:
"""Mock bring configuration entry."""
return MockConfigEntry(
domain=DOMAIN, data={CONF_EMAIL: EMAIL, CONF_PASSWORD: PASSWORD}, unique_id=UUID
)

View File

@ -0,0 +1,111 @@
"""Test the Bring! config flow."""
from unittest.mock import AsyncMock, Mock
import pytest
from python_bring_api.exceptions import (
BringAuthException,
BringParseException,
BringRequestException,
)
from homeassistant import config_entries
from homeassistant.components.bring.const import DOMAIN
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .conftest import EMAIL, PASSWORD
from tests.common import MockConfigEntry
MOCK_DATA_STEP = {
CONF_EMAIL: EMAIL,
CONF_PASSWORD: PASSWORD,
}
async def test_form(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_bring_client: Mock
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_STEP,
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == MOCK_DATA_STEP["email"]
assert result["data"] == MOCK_DATA_STEP
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("raise_error", "text_error"),
[
(BringRequestException(), "cannot_connect"),
(BringAuthException(), "invalid_auth"),
(BringParseException(), "unknown"),
(IndexError(), "unknown"),
],
)
async def test_flow_user_init_data_unknown_error_and_recover(
hass: HomeAssistant, mock_bring_client: Mock, raise_error, text_error
) -> None:
"""Test unknown errors."""
mock_bring_client.login.side_effect = raise_error
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_STEP,
)
assert result["type"] == FlowResultType.FORM
assert result["errors"]["base"] == text_error
# Recover
mock_bring_client.login.side_effect = None
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_STEP,
)
assert result["type"] == "create_entry"
assert result["result"].title == MOCK_DATA_STEP["email"]
assert result["data"] == MOCK_DATA_STEP
async def test_flow_user_init_data_already_configured(
hass: HomeAssistant, mock_bring_client: Mock, bring_config_entry: MockConfigEntry
) -> None:
"""Test we abort user data set when entry is already configured."""
bring_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_STEP,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@ -0,0 +1,63 @@
"""Unit tests for the bring integration."""
from unittest.mock import Mock
import pytest
from homeassistant.components.bring import (
BringAuthException,
BringParseException,
BringRequestException,
)
from homeassistant.components.bring.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(
hass: HomeAssistant,
bring_config_entry: MockConfigEntry,
) -> None:
"""Mock setup of the bring integration."""
bring_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(bring_config_entry.entry_id)
await hass.async_block_till_done()
async def test_load_unload(
hass: HomeAssistant,
mock_bring_client: Mock,
bring_config_entry: MockConfigEntry,
) -> None:
"""Test loading and unloading of the config entry."""
await setup_integration(hass, bring_config_entry)
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert bring_config_entry.state == ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(bring_config_entry.entry_id)
assert bring_config_entry.state == ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
("exception", "status"),
[
(BringRequestException, ConfigEntryState.SETUP_RETRY),
(BringAuthException, ConfigEntryState.SETUP_ERROR),
(BringParseException, ConfigEntryState.SETUP_RETRY),
],
)
async def test_init_failure(
hass: HomeAssistant,
mock_bring_client: Mock,
status: ConfigEntryState,
exception: Exception,
bring_config_entry: MockConfigEntry | None,
) -> None:
"""Test an initialization error on integration load."""
mock_bring_client.login.side_effect = exception
await setup_integration(hass, bring_config_entry)
assert bring_config_entry.state == status