Skip to content

Commit

Permalink
Add OurGroceries integration (#103387)
Browse files Browse the repository at this point in the history
* Add OurGroceries integration

* Handle review comments

* Fix coordinator test

* Additional review comments

* Address code review comments

* Remove devices
  • Loading branch information
OnFreund committed Nov 26, 2023
1 parent 8a1f7b6 commit 6e5dfa0
Show file tree
Hide file tree
Showing 17 changed files with 781 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the OurGroceries integration."""

DOMAIN = "ourgroceries"
41 changes: 41 additions & 0 deletions homeassistant/components/ourgroceries/coordinator.py
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions homeassistant/components/ourgroceries/manifest.json
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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%]"
}
}
}
118 changes: 118 additions & 0 deletions homeassistant/components/ourgroceries/todo.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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}}

0 comments on commit 6e5dfa0

Please sign in to comment.