Add OurGroceries integration (#103387)

* Add OurGroceries integration

* Handle review comments

* Fix coordinator test

* Additional review comments

* Address code review comments

* Remove devices
pull/104557/head
On Freund 2023-11-26 18:38:47 +02:00 committed by GitHub
parent 8a1f7b6802
commit 6e5dfa0e9b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 781 additions and 0 deletions

View File

@ -930,6 +930,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/oru/ @bvlaicu
/homeassistant/components/otbr/ @home-assistant/core
/tests/components/otbr/ @home-assistant/core
/homeassistant/components/ourgroceries/ @OnFreund
/tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev
/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev
/homeassistant/components/ovo_energy/ @timmo001

View File

@ -0,0 +1,50 @@
"""The OurGroceries integration."""
from __future__ import annotations
from asyncio import TimeoutError as AsyncIOTimeoutError
from aiohttp import ClientError
from ourgroceries import OurGroceries
from ourgroceries.exceptions import InvalidLoginException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
from .coordinator import OurGroceriesDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.TODO]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up OurGroceries from a config entry."""
hass.data.setdefault(DOMAIN, {})
data = entry.data
og = OurGroceries(data[CONF_USERNAME], data[CONF_PASSWORD])
lists = []
try:
await og.login()
lists = (await og.get_my_lists())["shoppingLists"]
except (AsyncIOTimeoutError, ClientError) as error:
raise ConfigEntryNotReady from error
except InvalidLoginException:
return False
coordinator = OurGroceriesDataUpdateCoordinator(hass, og, lists)
await coordinator.async_config_entry_first_refresh()
hass.data[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,57 @@
"""Config flow for OurGroceries integration."""
from __future__ import annotations
from asyncio import TimeoutError as AsyncIOTimeoutError
import logging
from typing import Any
from aiohttp import ClientError
from ourgroceries import OurGroceries
from ourgroceries.exceptions import InvalidLoginException
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for OurGroceries."""
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:
og = OurGroceries(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
try:
await og.login()
except (AsyncIOTimeoutError, ClientError):
errors["base"] = "cannot_connect"
except InvalidLoginException:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=user_input[CONF_USERNAME], 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 OurGroceries integration."""
DOMAIN = "ourgroceries"

View File

@ -0,0 +1,41 @@
"""The OurGroceries coordinator."""
from __future__ import annotations
from datetime import timedelta
import logging
from ourgroceries import OurGroceries
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
SCAN_INTERVAL = 60
_LOGGER = logging.getLogger(__name__)
class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]):
"""Class to manage fetching OurGroceries data."""
def __init__(
self, hass: HomeAssistant, og: OurGroceries, lists: list[dict]
) -> None:
"""Initialize global OurGroceries data updater."""
self.og = og
self.lists = lists
interval = timedelta(seconds=SCAN_INTERVAL)
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=interval,
)
async def _async_update_data(self) -> dict[str, dict]:
"""Fetch data from OurGroceries."""
return {
sl["id"]: (await self.og.get_list_items(list_id=sl["id"]))
for sl in self.lists
}

View File

@ -0,0 +1,9 @@
{
"domain": "ourgroceries",
"name": "OurGroceries",
"codeowners": ["@OnFreund"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ourgroceries",
"iot_class": "cloud_polling",
"requirements": ["ourgroceries==1.5.4"]
}

View File

@ -0,0 +1,20 @@
{
"config": {
"step": {
"user": {
"data": {
"username": "[%key:common::config_flow::data::username%]",
"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_device%]"
}
}
}

View File

@ -0,0 +1,118 @@
"""A todo platform for OurGroceries."""
import asyncio
from homeassistant.components.todo import (
TodoItem,
TodoItemStatus,
TodoListEntity,
TodoListEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import OurGroceriesDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the OurGroceries todo platform config entry."""
coordinator: OurGroceriesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
OurGroceriesTodoListEntity(coordinator, sl["id"], sl["name"])
for sl in coordinator.lists
)
class OurGroceriesTodoListEntity(
CoordinatorEntity[OurGroceriesDataUpdateCoordinator], TodoListEntity
):
"""An OurGroceries TodoListEntity."""
_attr_has_entity_name = True
_attr_supported_features = (
TodoListEntityFeature.CREATE_TODO_ITEM
| TodoListEntityFeature.UPDATE_TODO_ITEM
| TodoListEntityFeature.DELETE_TODO_ITEM
)
def __init__(
self,
coordinator: OurGroceriesDataUpdateCoordinator,
list_id: str,
list_name: str,
) -> None:
"""Initialize TodoistTodoListEntity."""
super().__init__(coordinator=coordinator)
self._list_id = list_id
self._attr_unique_id = list_id
self._attr_name = list_name
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self.coordinator.data is None:
self._attr_todo_items = None
else:
def _completion_status(item):
if item.get("crossedOffAt", False):
return TodoItemStatus.COMPLETED
return TodoItemStatus.NEEDS_ACTION
self._attr_todo_items = [
TodoItem(
summary=item["name"],
uid=item["id"],
status=_completion_status(item),
)
for item in self.coordinator.data[self._list_id]["list"]["items"]
]
super()._handle_coordinator_update()
async def async_create_todo_item(self, item: TodoItem) -> None:
"""Create a To-do item."""
if item.status != TodoItemStatus.NEEDS_ACTION:
raise ValueError("Only active tasks may be created.")
await self.coordinator.og.add_item_to_list(
self._list_id, item.summary, auto_category=True
)
await self.coordinator.async_refresh()
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update a To-do item."""
if item.summary:
api_items = self.coordinator.data[self._list_id]["list"]["items"]
category = next(
api_item["categoryId"]
for api_item in api_items
if api_item["id"] == item.uid
)
await self.coordinator.og.change_item_on_list(
self._list_id, item.uid, category, item.summary
)
if item.status is not None:
cross_off = item.status == TodoItemStatus.COMPLETED
await self.coordinator.og.toggle_item_crossed_off(
self._list_id, item.uid, cross_off=cross_off
)
await self.coordinator.async_refresh()
async def async_delete_todo_items(self, uids: list[str]) -> None:
"""Delete a To-do item."""
await asyncio.gather(
*[
self.coordinator.og.remove_item_from_list(self._list_id, uid)
for uid in uids
]
)
await self.coordinator.async_refresh()
async def async_added_to_hass(self) -> None:
"""When entity is added to hass update state from existing coordinator data."""
await super().async_added_to_hass()
self._handle_coordinator_update()

View File

@ -347,6 +347,7 @@ FLOWS = {
"opower",
"oralb",
"otbr",
"ourgroceries",
"overkiz",
"ovo_energy",
"owntracks",

View File

@ -4152,6 +4152,12 @@
"config_flow": false,
"iot_class": "local_polling"
},
"ourgroceries": {
"name": "OurGroceries",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"overkiz": {
"name": "Overkiz",
"integration_type": "hub",

View File

@ -1425,6 +1425,9 @@ oru==0.1.11
# homeassistant.components.orvibo
orvibo==1.1.1
# homeassistant.components.ourgroceries
ourgroceries==1.5.4
# homeassistant.components.ovo_energy
ovoenergy==1.2.0

View File

@ -1095,6 +1095,9 @@ opower==0.0.39
# homeassistant.components.oralb
oralb-ble==0.17.6
# homeassistant.components.ourgroceries
ourgroceries==1.5.4
# homeassistant.components.ovo_energy
ovoenergy==1.2.0

View File

@ -0,0 +1,6 @@
"""Tests for the OurGroceries integration."""
def items_to_shopping_list(items: list) -> dict[dict[list]]:
"""Convert a list of items into a shopping list."""
return {"list": {"items": items}}

View File

@ -0,0 +1,68 @@
"""Common fixtures for the OurGroceries tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.ourgroceries import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from . import items_to_shopping_list
from tests.common import MockConfigEntry
USERNAME = "test-username"
PASSWORD = "test-password"
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.ourgroceries.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture(name="ourgroceries_config_entry")
def mock_ourgroceries_config_entry() -> MockConfigEntry:
"""Mock ourgroceries configuration."""
return MockConfigEntry(
domain=DOMAIN, data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
)
@pytest.fixture(name="items")
def mock_items() -> dict:
"""Mock a collection of shopping list items."""
return []
@pytest.fixture(name="ourgroceries")
def mock_ourgroceries(items: list[dict]) -> AsyncMock:
"""Mock the OurGroceries api."""
og = AsyncMock()
og.login.return_value = True
og.get_my_lists.return_value = {
"shoppingLists": [{"id": "test_list", "name": "Test List"}]
}
og.get_list_items.return_value = items_to_shopping_list(items)
return og
@pytest.fixture(name="setup_integration")
async def mock_setup_integration(
hass: HomeAssistant,
ourgroceries: AsyncMock,
ourgroceries_config_entry: MockConfigEntry,
) -> None:
"""Mock setup of the ourgroceries integration."""
ourgroceries_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.ourgroceries.OurGroceries", return_value=ourgroceries
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield

View File

@ -0,0 +1,96 @@
"""Test the OurGroceries config flow."""
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant import config_entries
from homeassistant.components.ourgroceries.config_flow import (
AsyncIOTimeoutError,
ClientError,
InvalidLoginException,
)
from homeassistant.components.ourgroceries.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> 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"] == {}
with patch(
"homeassistant.components.ourgroceries.config_flow.OurGroceries.login",
return_value=True,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "test-username"
assert result2["data"] == {
"username": "test-username",
"password": "test-password",
}
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "error"),
[
(InvalidLoginException, "invalid_auth"),
(ClientError, "cannot_connect"),
(AsyncIOTimeoutError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_form_error(
hass: HomeAssistant, exception: Exception, error: str, mock_setup_entry: AsyncMock
) -> None:
"""Test we handle form errors."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.ourgroceries.config_flow.OurGroceries.login",
side_effect=exception,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
},
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": error}
with patch(
"homeassistant.components.ourgroceries.config_flow.OurGroceries.login",
return_value=True,
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
"username": "test-username",
"password": "test-password",
},
)
assert result3["type"] == FlowResultType.CREATE_ENTRY
assert result3["title"] == "test-username"
assert result3["data"] == {
"username": "test-username",
"password": "test-password",
}
assert len(mock_setup_entry.mock_calls) == 1

View File

@ -0,0 +1,55 @@
"""Unit tests for the OurGroceries integration."""
from unittest.mock import AsyncMock
import pytest
from homeassistant.components.ourgroceries import (
AsyncIOTimeoutError,
ClientError,
InvalidLoginException,
)
from homeassistant.components.ourgroceries.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_load_unload(
hass: HomeAssistant,
setup_integration: None,
ourgroceries_config_entry: MockConfigEntry | None,
) -> None:
"""Test loading and unloading of the config entry."""
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert ourgroceries_config_entry.state == ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(ourgroceries_config_entry.entry_id)
assert ourgroceries_config_entry.state == ConfigEntryState.NOT_LOADED
@pytest.fixture
def login_with_error(exception, ourgroceries: AsyncMock):
"""Fixture to simulate error on login."""
ourgroceries.login.side_effect = (exception,)
@pytest.mark.parametrize(
("exception", "status"),
[
(InvalidLoginException, ConfigEntryState.SETUP_ERROR),
(ClientError, ConfigEntryState.SETUP_RETRY),
(AsyncIOTimeoutError, ConfigEntryState.SETUP_RETRY),
],
)
async def test_init_failure(
hass: HomeAssistant,
login_with_error,
setup_integration: None,
status: ConfigEntryState,
ourgroceries_config_entry: MockConfigEntry | None,
) -> None:
"""Test an initialization error on integration load."""
assert ourgroceries_config_entry.state == status

View File

@ -0,0 +1,243 @@
"""Unit tests for the OurGroceries todo platform."""
from asyncio import TimeoutError as AsyncIOTimeoutError
from unittest.mock import AsyncMock
from aiohttp import ClientError
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.ourgroceries.coordinator import SCAN_INTERVAL
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_component import async_update_entity
from . import items_to_shopping_list
from tests.common import async_fire_time_changed
@pytest.mark.parametrize(
("items", "expected_state"),
[
([], "0"),
([{"id": "12345", "name": "Soda"}], "1"),
([{"id": "12345", "name": "Soda", "crossedOffAt": 1699107501}], "0"),
(
[
{"id": "12345", "name": "Soda"},
{"id": "54321", "name": "Milk"},
],
"2",
),
],
)
async def test_todo_item_state(
hass: HomeAssistant,
setup_integration: None,
expected_state: str,
) -> None:
"""Test for a shopping list entity state."""
state = hass.states.get("todo.test_list")
assert state
assert state.state == expected_state
async def test_add_todo_list_item(
hass: HomeAssistant,
setup_integration: None,
ourgroceries: AsyncMock,
) -> None:
"""Test for adding an item."""
state = hass.states.get("todo.test_list")
assert state
assert state.state == "0"
ourgroceries.add_item_to_list = AsyncMock()
# Fake API response when state is refreshed after create
ourgroceries.get_list_items.return_value = items_to_shopping_list(
[{"id": "12345", "name": "Soda"}]
)
await hass.services.async_call(
TODO_DOMAIN,
"add_item",
{"item": "Soda"},
target={"entity_id": "todo.test_list"},
blocking=True,
)
args = ourgroceries.add_item_to_list.call_args
assert args
assert args.args == ("test_list", "Soda")
assert args.kwargs.get("auto_category") is True
# Verify state is refreshed
state = hass.states.get("todo.test_list")
assert state
assert state.state == "1"
@pytest.mark.parametrize(("items"), [[{"id": "12345", "name": "Soda"}]])
async def test_update_todo_item_status(
hass: HomeAssistant,
setup_integration: None,
ourgroceries: AsyncMock,
) -> None:
"""Test for updating the completion status of an item."""
state = hass.states.get("todo.test_list")
assert state
assert state.state == "1"
ourgroceries.toggle_item_crossed_off = AsyncMock()
# Fake API response when state is refreshed after crossing off
ourgroceries.get_list_items.return_value = items_to_shopping_list(
[{"id": "12345", "name": "Soda", "crossedOffAt": 1699107501}]
)
await hass.services.async_call(
TODO_DOMAIN,
"update_item",
{"item": "12345", "status": "completed"},
target={"entity_id": "todo.test_list"},
blocking=True,
)
assert ourgroceries.toggle_item_crossed_off.called
args = ourgroceries.toggle_item_crossed_off.call_args
assert args
assert args.args == ("test_list", "12345")
assert args.kwargs.get("cross_off") is True
# Verify state is refreshed
state = hass.states.get("todo.test_list")
assert state
assert state.state == "0"
# Fake API response when state is refreshed after reopen
ourgroceries.get_list_items.return_value = items_to_shopping_list(
[{"id": "12345", "name": "Soda"}]
)
await hass.services.async_call(
TODO_DOMAIN,
"update_item",
{"item": "12345", "status": "needs_action"},
target={"entity_id": "todo.test_list"},
blocking=True,
)
assert ourgroceries.toggle_item_crossed_off.called
args = ourgroceries.toggle_item_crossed_off.call_args
assert args
assert args.args == ("test_list", "12345")
assert args.kwargs.get("cross_off") is False
# Verify state is refreshed
state = hass.states.get("todo.test_list")
assert state
assert state.state == "1"
@pytest.mark.parametrize(
("items"), [[{"id": "12345", "name": "Soda", "categoryId": "test_category"}]]
)
async def test_update_todo_item_summary(
hass: HomeAssistant,
setup_integration: None,
ourgroceries: AsyncMock,
) -> None:
"""Test for updating an item summary."""
state = hass.states.get("todo.test_list")
assert state
assert state.state == "1"
ourgroceries.change_item_on_list = AsyncMock()
# Fake API response when state is refreshed update
ourgroceries.get_list_items.return_value = items_to_shopping_list(
[{"id": "12345", "name": "Milk"}]
)
await hass.services.async_call(
TODO_DOMAIN,
"update_item",
{"item": "12345", "rename": "Milk"},
target={"entity_id": "todo.test_list"},
blocking=True,
)
assert ourgroceries.change_item_on_list
args = ourgroceries.change_item_on_list.call_args
assert args.args == ("test_list", "12345", "test_category", "Milk")
@pytest.mark.parametrize(
("items"),
[
[
{"id": "12345", "name": "Soda"},
{"id": "54321", "name": "Milk"},
]
],
)
async def test_remove_todo_item(
hass: HomeAssistant,
setup_integration: None,
ourgroceries: AsyncMock,
) -> None:
"""Test for removing an item."""
state = hass.states.get("todo.test_list")
assert state
assert state.state == "2"
ourgroceries.remove_item_from_list = AsyncMock()
# Fake API response when state is refreshed after remove
ourgroceries.get_list_items.return_value = items_to_shopping_list([])
await hass.services.async_call(
TODO_DOMAIN,
"remove_item",
{"item": ["12345", "54321"]},
target={"entity_id": "todo.test_list"},
blocking=True,
)
assert ourgroceries.remove_item_from_list.call_count == 2
args = ourgroceries.remove_item_from_list.call_args_list
assert args[0].args == ("test_list", "12345")
assert args[1].args == ("test_list", "54321")
await async_update_entity(hass, "todo.test_list")
state = hass.states.get("todo.test_list")
assert state
assert state.state == "0"
@pytest.mark.parametrize(
("exception"),
[
(ClientError),
(AsyncIOTimeoutError),
],
)
async def test_coordinator_error(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
setup_integration: None,
ourgroceries: AsyncMock,
exception: Exception,
) -> None:
"""Test error on coordinator update."""
state = hass.states.get("todo.test_list")
assert state.state == "0"
ourgroceries.get_list_items.side_effect = exception
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("todo.test_list")
assert state.state == STATE_UNAVAILABLE