async_added_to_hass listed the switchable boxes inline, one fetch per box popup, so on a slower connection the platform setup ran past SLOW_SETUP_MAX_WAIT and the whole config entry was cancelled into setup_error — every entity unavailable despite valid credentials. Move the listing to a background task so setup never blocks on it; the options fill in once it returns. Also bound every portal request to a 30s timeout so one hung request can no longer drag a refresh, or a first setup, past Home Assistant's own limits and fail it outright. Regression covered in tests/test_setup_hygiene.py.
176 lines
6.7 KiB
Python
176 lines
6.7 KiB
Python
"""The order's ADD-ONS as a to-do list.
|
|
|
|
Two different things arrive in a delivery and only one of them is a list you
|
|
can edit:
|
|
|
|
* The **produce box** — the Georgia Grown Small Box and its contents. Fresh
|
|
Harvest fills it; you pick the box, not the carrots in it. Swapping it is
|
|
"Change Basket" on the portal, and its contents are read-only here, exposed
|
|
as the `produce` attribute of `sensor.*_next_delivery_items`.
|
|
* The **add-ons** — everything you chose individually. These are genuinely
|
|
addable and removable, one endpoint each way.
|
|
|
|
This entity is the add-ons, because those are the ones where "add X" and
|
|
"remove X" mean something. Listing box produce here would invite a delete that
|
|
has no endpoint behind it, and the failure would surface as a confusing error
|
|
rather than "that is not a thing you can do".
|
|
|
|
It exists so Home Assistant's own conversation agent can manage the order with
|
|
no bespoke voice work: the built-in assistant already knows how to add and
|
|
remove to-do items, so "add bananas to my Fresh Harvest add-ons" routes through
|
|
`HassListAddItem` and lands here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import urllib.parse
|
|
|
|
from homeassistant.components.todo import (
|
|
TodoItem,
|
|
TodoItemStatus,
|
|
TodoListEntity,
|
|
TodoListEntityFeature,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
|
|
from . import FreshHarvestConfigEntry
|
|
from .api import REQUEST_TIMEOUT, FreshHarvestError
|
|
from .coordinator import FreshHarvestCoordinator
|
|
from .entity import FreshHarvestEntity
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
ALGOLIA_JS = "/_home/JavaScript/search_algolia.js"
|
|
_CREDS_RE = re.compile(r'algoliasearch\(\s*"([^"]+)"\s*,\s*"([^"]+)"', re.S)
|
|
_INDEX_RE = re.compile(r'indexName:\s*"([^"]+)"')
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: FreshHarvestConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""Set up the to-do platform."""
|
|
async_add_entities([FreshHarvestBox(entry.runtime_data, entry)])
|
|
|
|
|
|
class FreshHarvestBox(FreshHarvestEntity, TodoListEntity):
|
|
"""Everything in the upcoming delivery, as a list."""
|
|
|
|
_attr_translation_key = "add_ons"
|
|
_attr_supported_features = (
|
|
TodoListEntityFeature.CREATE_TODO_ITEM
|
|
| TodoListEntityFeature.DELETE_TODO_ITEM
|
|
)
|
|
|
|
def __init__(
|
|
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
|
|
) -> None:
|
|
super().__init__(coordinator, entry, "add_ons")
|
|
|
|
@property
|
|
def todo_items(self) -> list[TodoItem] | None:
|
|
"""The open order's add-ons — the part of a delivery you control.
|
|
|
|
Scoped to the *open* order rather than the next arriving one, because a
|
|
delete has to act on the order actually being shown; the next delivery
|
|
is usually already locked for packing.
|
|
"""
|
|
snapshot = self.coordinator.data
|
|
order = snapshot.open_order
|
|
if order is None:
|
|
return None
|
|
items: list[TodoItem] = []
|
|
for item in order.addons:
|
|
label = " ".join(
|
|
p for p in (str(item.quantity or ""), item.name, item.unit) if p
|
|
)
|
|
if item.price is not None:
|
|
label = f"{label} — ${item.price:,.2f}"
|
|
items.append(
|
|
TodoItem(
|
|
summary=label,
|
|
uid=item.name,
|
|
# Not a checklist: nothing here is ever "done", an add-on
|
|
# either is or is not in the delivery.
|
|
status=TodoItemStatus.NEEDS_ACTION,
|
|
)
|
|
)
|
|
return items
|
|
|
|
async def _algolia_lookup(self, query: str) -> tuple[str, str]:
|
|
"""Resolve a spoken name to (item_id, item_name) via the site's index.
|
|
|
|
Credentials are read from the site's own JS rather than hardcoded, so a
|
|
key rotation fixes itself and a third party's key never enters this repo.
|
|
"""
|
|
client = self.coordinator.client
|
|
js = await client.async_fetch(ALGOLIA_JS)
|
|
creds, index = _CREDS_RE.search(js), _INDEX_RE.search(js)
|
|
if not (creds and index):
|
|
raise HomeAssistantError(
|
|
"could not read the catalogue configuration from the site"
|
|
)
|
|
app, key = creds.groups()
|
|
payload = json.dumps(
|
|
{"params": urllib.parse.urlencode({"query": query, "hitsPerPage": 5})}
|
|
).encode()
|
|
session = client._session # noqa: SLF001 — same package
|
|
async with session.post(
|
|
f"https://{app}-dsn.algolia.net/1/indexes/{index.group(1)}/query",
|
|
data=payload,
|
|
headers={
|
|
"X-Algolia-API-Key": key,
|
|
"X-Algolia-Application-Id": app,
|
|
"Content-Type": "application/json",
|
|
},
|
|
timeout=REQUEST_TIMEOUT,
|
|
) as resp:
|
|
resp.raise_for_status()
|
|
hits = (await resp.json()).get("hits") or []
|
|
if not hits:
|
|
raise HomeAssistantError(f"nothing in the catalogue matches {query!r}")
|
|
return str(hits[0]["ID"]), str(hits[0].get("Name") or query)
|
|
|
|
async def async_create_todo_item(self, item: TodoItem) -> None:
|
|
"""Add an item to the order.
|
|
|
|
The site only renders an add control for something orderable right now,
|
|
so an out-of-stock item fails here rather than silently doing nothing.
|
|
"""
|
|
query = (item.summary or "").strip()
|
|
if not query:
|
|
raise HomeAssistantError("no item name given")
|
|
|
|
item_id, name = await self._algolia_lookup(query)
|
|
try:
|
|
result = await self.coordinator.actions.async_add_item(
|
|
item_id, dry_run=False, name=name
|
|
)
|
|
except FreshHarvestError as err:
|
|
self.fire_action("add_item", False, query, str(err))
|
|
raise HomeAssistantError(f"could not add {name}: {err}") from err
|
|
|
|
self.fire_action("add_item", True, name, result.detail)
|
|
await self.coordinator.async_request_refresh()
|
|
|
|
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
|
"""Take items back out of the order."""
|
|
for uid in uids:
|
|
item_id, name = await self._algolia_lookup(uid)
|
|
try:
|
|
await self.coordinator.actions.async_remove_item(
|
|
item_id, dry_run=False, name=name
|
|
)
|
|
except FreshHarvestError as err:
|
|
self.fire_action("remove_item", False, uid, str(err))
|
|
raise HomeAssistantError(f"could not remove {name}: {err}") from err
|
|
self.fire_action("remove_item", True, name, "removed")
|
|
await self.coordinator.async_request_refresh()
|
|
|