-
-
Notifications
You must be signed in to change notification settings - Fork 28.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add OurGroceries integration (#103387)
* Add OurGroceries integration * Handle review comments * Fix coordinator test * Additional review comments * Address code review comments * Remove devices
- Loading branch information
Showing
17 changed files
with
781 additions
and
0 deletions.
There are no files selected for viewing
Validating CODEOWNERS rules …
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
"""Constants for the OurGroceries integration.""" | ||
|
||
DOMAIN = "ourgroceries" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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%]" | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -347,6 +347,7 @@ | |
"opower", | ||
"oralb", | ||
"otbr", | ||
"ourgroceries", | ||
"overkiz", | ||
"ovo_energy", | ||
"owntracks", | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}} |
Oops, something went wrong.