Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OurGroceries integration #103387

Merged
merged 6 commits into from
Nov 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions homeassistant/components/ourgroceries/__init__.py
@@ -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
57 changes: 57 additions & 0 deletions homeassistant/components/ourgroceries/config_flow.py
@@ -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
)
3 changes: 3 additions & 0 deletions homeassistant/components/ourgroceries/const.py
@@ -0,0 +1,3 @@
"""Constants for the OurGroceries integration."""

DOMAIN = "ourgroceries"
41 changes: 41 additions & 0 deletions homeassistant/components/ourgroceries/coordinator.py
@@ -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"]))
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
for sl in self.lists
}
9 changes: 9 additions & 0 deletions homeassistant/components/ourgroceries/manifest.json
@@ -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"]
}
20 changes: 20 additions & 0 deletions homeassistant/components/ourgroceries/strings.json
@@ -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%]"
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
118 changes: 118 additions & 0 deletions homeassistant/components/ourgroceries/todo.py
@@ -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(
OnFreund marked this conversation as resolved.
Show resolved Hide resolved
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

Check warning on line 59 in homeassistant/components/ourgroceries/todo.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/ourgroceries/todo.py#L59

Added line #L59 was not covered by tests
else:

def _completion_status(item):
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
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.")

Check warning on line 80 in homeassistant/components/ourgroceries/todo.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/ourgroceries/todo.py#L80

Added line #L80 was not covered by tests
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()
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Expand Up @@ -347,6 +347,7 @@
"opower",
"oralb",
"otbr",
"ourgroceries",
"overkiz",
"ovo_energy",
"owntracks",
Expand Down
6 changes: 6 additions & 0 deletions homeassistant/generated/integrations.json
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions requirements_all.txt
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions tests/components/ourgroceries/__init__.py
@@ -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}}