From 5e9d721a0f190748d1446fbf18d85cf3a07715ae Mon Sep 17 00:00:00 2001 From: flan Date: Mon, 3 Aug 2026 20:20:15 +0000 Subject: [PATCH] Add restore, and separate the produce box from add-ons Restore is POST /s/submit/restore-delivery, wired to switch.turn_off. Its popup only exists once an order is actually skipped, which is why it could not be found earlier; verified end to end by skipping Aug 18 and restoring it. Fixes two bugs found while testing that. async_fetch treated popup and AJAX fragments as full pages, so the signed-in heuristic read them as logged out and skip could never run. And the to-do list mixed box produce with add-ons, which would have invited deletes with no endpoint behind them: only add-ons are add/removable, so the entity is now scoped to those. --- CHANGELOG.md | 23 ++++++++- custom_components/freshharvest/actions.py | 51 +++++++++++++++++-- custom_components/freshharvest/api.py | 11 ++-- custom_components/freshharvest/strings.json | 4 +- custom_components/freshharvest/switch.py | 24 +++++---- custom_components/freshharvest/todo.py | 43 +++++++++++----- .../freshharvest/translations/en.json | 4 +- 7 files changed, 122 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9adb09b..ee1c281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,10 +35,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 heading row, so an account with a live subscription reported zero. Cells are now picked by semantic class. Regression covered in `tests/test_actions.py`. +- Restore (un-skip) via `POST /s/submit/restore-delivery`, wired to + `switch.turn_off`. The restore popup only exists once an order is actually + skipped, which is why it could not be found until one was. Verified end to + end against a live order: skipped Aug 18, restored it, confirmed the account + returned to its previous state. + +### Fixed + +- Subscription parsing matched `.account-item-multi-fields`, which is the + heading row, so an account with a live subscription reported zero. Cells are + now picked by semantic class. Regression covered in `tests/test_actions.py`. +- `async_fetch` treated popup bodies and AJAX replies as full pages. Those are + fragments with no navigation, so the signed-in heuristic read every one as + logged out, re-authenticated pointlessly and then failed. They now pass + `is_page=False`. This blocked skip entirely. +- The to-do list mixed produce-box contents with add-ons. Only add-ons can be + added and removed — the box is chosen, not assembled — so listing produce + invited deletes with no endpoint behind them. The entity is now + `todo.fresh_harvest_add_ons`; box contents stay read-only on + `sensor.*_next_delivery_items`. + ### Known gaps -- Un-skip raises rather than guessing: the portal's restore control only - appears once an order is skipped, so its endpoint has never been observed. - Entities are deliberately NOT exposed to the conversation agent yet. ### Notes diff --git a/custom_components/freshharvest/actions.py b/custom_components/freshharvest/actions.py index 118a4a1..6b56194 100644 --- a/custom_components/freshharvest/actions.py +++ b/custom_components/freshharvest/actions.py @@ -39,6 +39,7 @@ DASHBOARD_PAUSE = "/p/dashboard/pause-deliveries" SHOP_ITEM = "/p/shop/item/{item_id}/x" SUBMIT_SKIP = "/s/submit/pause-delivery" +SUBMIT_RESTORE = "/s/submit/restore-delivery" SUBMIT_DONATE = "/s/submit/donate-basket" SUBMIT_SUBSCRIBE = "/s/submit/item-frequency" SUBMIT_HOLD = "/s/submit/pause-range-add" @@ -210,7 +211,9 @@ class FreshHarvestActions: raise FreshHarvestActionError("no skippable delivery on this account") for token in dict.fromkeys(tokens): - popup = await self._client.async_fetch(f"/x/popup/pause-delivery/{token}") + popup = await self._client.async_fetch( + f"/x/popup/pause-delivery/{token}", is_page=False + ) form = _find_form(BeautifulSoup(popup, "html.parser"), SUBMIT_SKIP) if form is None: # Several tokens on the page are for other popups and fall @@ -254,6 +257,43 @@ class FreshHarvestActions: "cutoff and locked for packing" ) + async def async_restore( + self, delivery_date: date, dry_run: bool = True + ) -> ActionResult: + """Un-skip a delivery. + + The restore popup only exists once an order is actually skipped — it is + not on the page beforehand — so this is the exact inverse of a skip and + raises when there is nothing to restore. + """ + page = await self._client.async_fetch(DASHBOARD_ORDERS) + tokens = re.findall(r'openPopup\("restore-delivery","([^"]+)"', page) + if not tokens: + raise FreshHarvestActionError("no skipped delivery to restore") + + for token in dict.fromkeys(tokens): + popup = await self._client.async_fetch( + f"/x/popup/restore-delivery/{token}", is_page=False + ) + soup = BeautifulSoup(popup, "html.parser") + form = _find_form(soup, SUBMIT_RESTORE) + if form is None: + continue + stated = _confirmation_date(soup.get_text(" ", strip=True)) + if stated is not None and stated != delivery_date: + continue + payload = _hidden_fields(form) | {"Continue": "Confirm"} + result = ActionResult( + action="restore", ok=True, target=delivery_date.isoformat(), + submitted=payload, dry_run=dry_run, + detail=f"restore {delivery_date}", + ) + if not dry_run: + await self._post(SUBMIT_RESTORE, payload) + return result + + raise FreshHarvestActionError(f"no restore token matched {delivery_date}") + # ---------------------------------------------------------------- donate async def async_donate(self, dry_run: bool = True) -> ActionResult: @@ -262,7 +302,9 @@ class FreshHarvestActions: m = re.search(r'openPopup\("donate-delivery","([^"]+)"', page) if not m: raise FreshHarvestActionError("no donatable delivery") - popup = await self._client.async_fetch(f"/x/popup/donate-delivery/{m.group(1)}") + popup = await self._client.async_fetch( + f"/x/popup/donate-delivery/{m.group(1)}", is_page=False + ) form = _find_form(BeautifulSoup(popup, "html.parser"), SUBMIT_DONATE) if form is None: raise FreshHarvestActionError("donate form not found") @@ -317,9 +359,8 @@ class FreshHarvestActions: detail=f"{mode} item {item_id}", ) if not dry_run: - # A fragment, not a page: no Sign Out control to detect, so bypass - # the signed-in check. The item fetch above already renewed the session. - await self._client._get(url) # noqa: SLF001 — same package + # A fragment, not a page — see async_fetch(is_page=...). + await self._client.async_fetch(url, is_page=False) return result # --------------------------------------------------------- subscriptions diff --git a/custom_components/freshharvest/api.py b/custom_components/freshharvest/api.py index 714048b..28d2419 100644 --- a/custom_components/freshharvest/api.py +++ b/custom_components/freshharvest/api.py @@ -366,18 +366,23 @@ class FreshHarvestClient: """ return "sign out" in body.lower() - async def async_fetch(self, path: str) -> str: - """Fetch any portal page, re-authenticating once if the session lapsed. + async def async_fetch(self, path: str, *, is_page: bool = True) -> str: + """Fetch from the portal, re-authenticating once if the session lapsed. The shared primitive for reads and for the token-scraping that every write action in `actions.py` has to do first. + + Set ``is_page=False`` for popup bodies and AJAX replies. Those are HTML + *fragments* with no navigation, so they never contain a Sign Out control + and the signed-in heuristic would read every one of them as logged out — + re-authenticating pointlessly and then failing. """ if not self._authenticated: await self.async_login() try: body = await self._get(path) - if not self._signed_in(body): + if is_page and not self._signed_in(body): self._authenticated = False await self.async_login() body = await self._get(path) diff --git a/custom_components/freshharvest/strings.json b/custom_components/freshharvest/strings.json index a709cce..e890347 100644 --- a/custom_components/freshharvest/strings.json +++ b/custom_components/freshharvest/strings.json @@ -81,8 +81,8 @@ } }, "todo": { - "box": { - "name": "Box" + "add_ons": { + "name": "Add-ons" } } } diff --git a/custom_components/freshharvest/switch.py b/custom_components/freshharvest/switch.py index 835ecf9..8f5e409 100644 --- a/custom_components/freshharvest/switch.py +++ b/custom_components/freshharvest/switch.py @@ -76,17 +76,19 @@ class FreshHarvestSkip(FreshHarvestEntity, SwitchEntity): await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: - """Un-skip — deliberately not implemented. - - The portal's restore control only appears once an order is already - skipped, so its endpoint has never been observed. Guessing at the way - back from a skip is exactly the kind of thing that should fail loudly - rather than post something plausible at a real order. - """ - raise HomeAssistantError( - "un-skipping is not supported yet: the restore endpoint has not " - "been confirmed. Restore it on freshharvest.com for now." - ) + """Un-skip the delivery.""" + order = self.coordinator.data.open_order + if order is None or order.delivery_date is None: + raise HomeAssistantError("no delivery to restore") + try: + result = await self.coordinator.actions.async_restore( + order.delivery_date, dry_run=False + ) + except FreshHarvestError as err: + self._fire("restore", False, str(order.delivery_date), str(err)) + raise HomeAssistantError(f"could not restore: {err}") from err + self._fire("restore", True, str(order.delivery_date), result.detail) + await self.coordinator.async_request_refresh() def _fire(self, action: str, ok: bool, target: str, detail: str) -> None: self.hass.bus.async_fire( diff --git a/custom_components/freshharvest/todo.py b/custom_components/freshharvest/todo.py index ed03ada..1196a3b 100644 --- a/custom_components/freshharvest/todo.py +++ b/custom_components/freshharvest/todo.py @@ -1,12 +1,24 @@ -"""The upcoming box as a to-do list. +"""The order's ADD-ONS as a to-do list. -This exists so Home Assistant's own conversation agent can manage the order with +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 box" routes through +remove to-do items, so "add bananas to my Fresh Harvest add-ons" routes through `HassListAddItem` and lands here. - -Adding an item resolves the spoken name against the site's own search index, -then adds the match to the open order. Removing one takes it back out. """ from __future__ import annotations @@ -51,7 +63,7 @@ async def async_setup_entry( class FreshHarvestBox(FreshHarvestEntity, TodoListEntity): """Everything in the upcoming delivery, as a list.""" - _attr_translation_key = "box" + _attr_translation_key = "add_ons" _attr_supported_features = ( TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM @@ -60,18 +72,23 @@ class FreshHarvestBox(FreshHarvestEntity, TodoListEntity): def __init__( self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry ) -> None: - super().__init__(coordinator, entry, "box") + super().__init__(coordinator, entry, "add_ons") self._entry = entry @property def todo_items(self) -> list[TodoItem] | None: - """The open order's contents, or the next delivery's once it locks.""" + """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 or snapshot.next_order + order = snapshot.open_order if order is None: return None items: list[TodoItem] = [] - for item in order.all_items: + for item in order.addons: label = " ".join( p for p in (str(item.quantity or ""), item.name, item.unit) if p ) @@ -81,8 +98,8 @@ class FreshHarvestBox(FreshHarvestEntity, TodoListEntity): TodoItem( summary=label, uid=item.name, - # The box is a standing order, not a checklist: nothing here - # is ever "done", it either is or is not in the delivery. + # Not a checklist: nothing here is ever "done", an add-on + # either is or is not in the delivery. status=TodoItemStatus.NEEDS_ACTION, ) ) diff --git a/custom_components/freshharvest/translations/en.json b/custom_components/freshharvest/translations/en.json index a709cce..e890347 100644 --- a/custom_components/freshharvest/translations/en.json +++ b/custom_components/freshharvest/translations/en.json @@ -81,8 +81,8 @@ } }, "todo": { - "box": { - "name": "Box" + "add_ons": { + "name": "Add-ons" } } }