diff --git a/.coveragerc b/.coveragerc index e20b26ff182..78420ac5836 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/CODEOWNERS b/CODEOWNERS index 56148d9e1be..1fb5f2d7e55 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py new file mode 100644 index 00000000000..e9501fc64b3 --- /dev/null +++ b/homeassistant/components/bring/__init__.py @@ -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 diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py new file mode 100644 index 00000000000..21774117ff6 --- /dev/null +++ b/homeassistant/components/bring/config_flow.py @@ -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 + ) diff --git a/homeassistant/components/bring/const.py b/homeassistant/components/bring/const.py new file mode 100644 index 00000000000..64a6ec67f85 --- /dev/null +++ b/homeassistant/components/bring/const.py @@ -0,0 +1,3 @@ +"""Constants for the Bring! integration.""" + +DOMAIN = "bring" diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py new file mode 100644 index 00000000000..a7bd4a35f43 --- /dev/null +++ b/homeassistant/components/bring/coordinator.py @@ -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 diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json new file mode 100644 index 00000000000..bc249ecea98 --- /dev/null +++ b/homeassistant/components/bring/manifest.json @@ -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"] +} diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json new file mode 100644 index 00000000000..de3677bf5f1 --- /dev/null +++ b/homeassistant/components/bring/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py new file mode 100644 index 00000000000..bd87a2d18de --- /dev/null +++ b/homeassistant/components/bring/todo.py @@ -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() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 17d4e6bcfa7..7c3e8a78940 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -77,6 +77,7 @@ FLOWS = { "bond", "bosch_shc", "braviatv", + "bring", "broadlink", "brother", "brottsplatskartan", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9cd0ad8785b..aa2ba3eae9c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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", diff --git a/requirements_all.txt b/requirements_all.txt index b50feb91260..9c66b6ce866 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 57e69672dd1..196faa6ded6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/bring/__init__.py b/tests/components/bring/__init__.py new file mode 100644 index 00000000000..1b13247f52e --- /dev/null +++ b/tests/components/bring/__init__.py @@ -0,0 +1 @@ +"""Tests for the Bring! integration.""" diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py new file mode 100644 index 00000000000..f8749d3dea9 --- /dev/null +++ b/tests/components/bring/conftest.py @@ -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 + ) diff --git a/tests/components/bring/test_config_flow.py b/tests/components/bring/test_config_flow.py new file mode 100644 index 00000000000..063d84a0e97 --- /dev/null +++ b/tests/components/bring/test_config_flow.py @@ -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" diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py new file mode 100644 index 00000000000..3c605143ba0 --- /dev/null +++ b/tests/components/bring/test_init.py @@ -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