160 lines
6.1 KiB
Python
160 lines
6.1 KiB
Python
"""Coordinator to fetch data from the Picnic API."""
|
|
import copy
|
|
from datetime import timedelta
|
|
import logging
|
|
|
|
import async_timeout
|
|
from python_picnic_api import PicnicAPI
|
|
from python_picnic_api.session import PicnicAuthError
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import CONF_ACCESS_TOKEN
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
|
|
from .const import ADDRESS, CART_DATA, LAST_ORDER_DATA, NEXT_DELIVERY_DATA, SLOT_DATA
|
|
|
|
|
|
class PicnicUpdateCoordinator(DataUpdateCoordinator):
|
|
"""The coordinator to fetch data from the Picnic API at a set interval."""
|
|
|
|
def __init__(
|
|
self,
|
|
hass: HomeAssistant,
|
|
picnic_api_client: PicnicAPI,
|
|
config_entry: ConfigEntry,
|
|
) -> None:
|
|
"""Initialize the coordinator with the given Picnic API client."""
|
|
self.picnic_api_client = picnic_api_client
|
|
self.config_entry = config_entry
|
|
self._user_address = None
|
|
|
|
logger = logging.getLogger(__name__)
|
|
super().__init__(
|
|
hass,
|
|
logger,
|
|
name="Picnic coordinator",
|
|
update_interval=timedelta(minutes=30),
|
|
)
|
|
|
|
async def _async_update_data(self) -> dict:
|
|
"""Fetch data from API endpoint."""
|
|
try:
|
|
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
|
|
# handled by the data update coordinator.
|
|
async with async_timeout.timeout(10):
|
|
data = await self.hass.async_add_executor_job(self.fetch_data)
|
|
|
|
# Update the auth token in the config entry if applicable
|
|
self._update_auth_token()
|
|
|
|
# Return the fetched data
|
|
return data
|
|
except ValueError as error:
|
|
raise UpdateFailed(f"API response was malformed: {error}") from error
|
|
except PicnicAuthError as error:
|
|
raise ConfigEntryAuthFailed from error
|
|
|
|
def fetch_data(self):
|
|
"""Fetch the data from the Picnic API and return a flat dict with only needed sensor data."""
|
|
# Fetch from the API and pre-process the data
|
|
if not (cart := self.picnic_api_client.get_cart()):
|
|
raise UpdateFailed("API response doesn't contain expected data.")
|
|
|
|
next_delivery, last_order = self._get_order_data()
|
|
slot_data = self._get_slot_data(cart)
|
|
|
|
return {
|
|
ADDRESS: self._get_address(),
|
|
CART_DATA: cart,
|
|
SLOT_DATA: slot_data,
|
|
NEXT_DELIVERY_DATA: next_delivery,
|
|
LAST_ORDER_DATA: last_order,
|
|
}
|
|
|
|
def _get_address(self):
|
|
"""Get the address that identifies the Picnic service."""
|
|
if self._user_address is None:
|
|
address = self.picnic_api_client.get_user()["address"]
|
|
self._user_address = f'{address["street"]} {address["house_number"]}{address["house_number_ext"]}'
|
|
|
|
return self._user_address
|
|
|
|
@staticmethod
|
|
def _get_slot_data(cart: dict) -> dict:
|
|
"""Get the selected slot, if it's explicitly selected."""
|
|
selected_slot = cart.get("selected_slot", {})
|
|
available_slots = cart.get("delivery_slots", [])
|
|
|
|
if selected_slot.get("state") == "EXPLICIT":
|
|
slot_data = filter(
|
|
lambda slot: slot.get("slot_id") == selected_slot.get("slot_id"),
|
|
available_slots,
|
|
)
|
|
if slot_data:
|
|
return next(slot_data)
|
|
|
|
return {}
|
|
|
|
def _get_order_data(self) -> tuple[dict, dict]:
|
|
"""Get data of the last order from the list of deliveries."""
|
|
# Get the deliveries
|
|
deliveries = self.picnic_api_client.get_deliveries(summary=True)
|
|
|
|
# Determine the last order and return an empty dict if there is none
|
|
try:
|
|
# Filter on status CURRENT and select the last on the list which is the first one to be delivered
|
|
# Make a deepcopy because some references are local
|
|
next_deliveries = list(
|
|
filter(lambda d: d["status"] == "CURRENT", deliveries)
|
|
)
|
|
next_delivery = (
|
|
copy.deepcopy(next_deliveries[-1]) if next_deliveries else {}
|
|
)
|
|
last_order = copy.deepcopy(deliveries[0]) if deliveries else {}
|
|
except (KeyError, TypeError):
|
|
# A KeyError or TypeError indicate that the response contains unexpected data
|
|
return {}, {}
|
|
|
|
# Get the next order's position details if there is an undelivered order
|
|
delivery_position = {}
|
|
if next_delivery and not next_delivery.get("delivery_time"):
|
|
try:
|
|
delivery_position = self.picnic_api_client.get_delivery_position(
|
|
next_delivery["delivery_id"]
|
|
)
|
|
except ValueError:
|
|
# No information yet can mean an empty response
|
|
pass
|
|
|
|
# Determine the ETA, if available, the one from the delivery position API is more precise
|
|
# but, it's only available shortly before the actual delivery.
|
|
next_delivery["eta"] = delivery_position.get(
|
|
"eta_window", next_delivery.get("eta2", {})
|
|
)
|
|
if "eta2" in next_delivery:
|
|
del next_delivery["eta2"]
|
|
|
|
# Determine the total price by adding up the total price of all sub-orders
|
|
total_price = 0
|
|
for order in last_order.get("orders", []):
|
|
total_price += order.get("total_price", 0)
|
|
last_order["total_price"] = total_price
|
|
|
|
# Make sure delivery_time is a dict
|
|
last_order.setdefault("delivery_time", {})
|
|
|
|
return next_delivery, last_order
|
|
|
|
@callback
|
|
def _update_auth_token(self):
|
|
"""Set the updated authentication token."""
|
|
updated_token = self.picnic_api_client.session.auth_token
|
|
if self.config_entry.data.get(CONF_ACCESS_TOKEN) != updated_token:
|
|
# Create an updated data dict
|
|
data = {**self.config_entry.data, CONF_ACCESS_TOKEN: updated_token}
|
|
|
|
# Update the config entry
|
|
self.hass.config_entries.async_update_entry(self.config_entry, data=data)
|