Add restore, and separate the produce box from add-ons
Validate / hassfest (push) Failing after 8s
Validate / HACS (push) Failing after 17s
Validate / pytest (push) Failing after 8s

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.
This commit is contained in:
flan
2026-08-03 20:20:15 +00:00
parent 551d3240a2
commit 5e9d721a0f
7 changed files with 122 additions and 38 deletions
+21 -2
View File
@@ -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
+46 -5
View File
@@ -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
+8 -3
View File
@@ -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)
+2 -2
View File
@@ -81,8 +81,8 @@
}
},
"todo": {
"box": {
"name": "Box"
"add_ons": {
"name": "Add-ons"
}
}
}
+13 -11
View File
@@ -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(
+30 -13
View File
@@ -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,
)
)
@@ -81,8 +81,8 @@
}
},
"todo": {
"box": {
"name": "Box"
"add_ons": {
"name": "Add-ons"
}
}
}