Wire produce-box switching as a select entity
Validate / hassfest (push) Failing after 6s
Validate / pytest (push) Failing after 10s
Validate / HACS (push) Failing after 17s

The boxes are not in the Algolia catalogue and their category pages look empty
because the grid calls every option 'Georgia Box' — each option's real name is
only in its own select-basket popup, so listing reads them there.

POST /s/submit/select-basket carries a scope the add-on endpoints do not:
popup-toggle is 'do' for the next delivery or 'so' for the standing order. The
select uses 'do', because a mistaken permanent change is the worse one to undo.

Also makes the popup regexes whitespace-tolerant. The site writes both
openPopup("x","y") and openPopup("x", "y"), and the strict form silently
matched nothing on the basket pages.
This commit is contained in:
flan
2026-08-03 20:31:13 +00:00
parent 0a218acd45
commit e7427082e3
7 changed files with 250 additions and 3 deletions
+5
View File
@@ -56,6 +56,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`todo.fresh_harvest_add_ons`; box contents stay read-only on
`sensor.*_next_delivery_items`.
- Produce box switching: `POST /s/submit/select-basket`, exposed as
`select.fresh_harvest_produce_box` with all ten boxes. Switching a box is not
adding an add-on — you change which box arrives, not what is inside it — so
it is a select, where add-ons are a to-do list.
### Known gaps
- Entities are deliberately NOT exposed to the conversation agent yet.
@@ -13,6 +13,7 @@ from .coordinator import FreshHarvestCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.TODO,
+126 -3
View File
@@ -43,6 +43,16 @@ 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"
SUBMIT_BASKET = "/s/submit/select-basket"
BASKET_TYPES = "/p/shop/basket-types"
BASKET_GROUPS = (
"georgia-grown-baskets",
"mixed-fruit-and-veggie-baskets",
"fruit-basket",
)
# The two submit buttons set this before posting: "do" = this delivery only,
# "so" = the standing order, i.e. every future box.
SCOPE_ONCE, SCOPE_STANDING = "do", "so"
AJAX_ORDER_MANAGE = "/p/Ajax/order-manage/{mode}/{hash}/-/false/{ts}"
# id='FrequencyID' but name='popup-toggle' — the id is a decoy, the POST field
@@ -206,7 +216,7 @@ class FreshHarvestActions:
submit and this raises rather than inventing one.
"""
page = await self._client.async_fetch(DASHBOARD_ORDERS)
tokens = re.findall(r'openPopup\("pause-delivery","([^"]+)"', page)
tokens = re.findall(r'openPopup\("pause-delivery",\s*"([^"]+)"', page)
if not tokens:
raise FreshHarvestActionError("no skippable delivery on this account")
@@ -267,7 +277,7 @@ class FreshHarvestActions:
raises when there is nothing to restore.
"""
page = await self._client.async_fetch(DASHBOARD_ORDERS)
tokens = re.findall(r'openPopup\("restore-delivery","([^"]+)"', page)
tokens = re.findall(r'openPopup\("restore-delivery",\s*"([^"]+)"', page)
if not tokens:
raise FreshHarvestActionError("no skipped delivery to restore")
@@ -299,7 +309,7 @@ class FreshHarvestActions:
async def async_donate(self, dry_run: bool = True) -> ActionResult:
"""Donate the upcoming box. One-way — there is no undo in the UI."""
page = await self._client.async_fetch(DASHBOARD_ORDERS)
m = re.search(r'openPopup\("donate-delivery","([^"]+)"', page)
m = re.search(r'openPopup\("donate-delivery",\s*"([^"]+)"', page)
if not m:
raise FreshHarvestActionError("no donatable delivery")
popup = await self._client.async_fetch(
@@ -441,3 +451,116 @@ class FreshHarvestActions:
async def async_list_vacation_holds(self) -> list[VacationHold]:
return parse_vacation_holds(await self._client.async_fetch(DASHBOARD_PAUSE))
@dataclass
class Basket:
"""A produce box you can switch to."""
item_id: str
name: str
is_current: bool = False
token: str = ""
class BasketMixin:
"""Produce-box switching, mixed into FreshHarvestActions below.
A box is not an add-on: you swap which box arrives, you do not add or
remove the produce inside it. The switch also has a scope the add-on
endpoints do not — one delivery, or every future one.
"""
async def async_list_baskets(self) -> list[Basket]:
"""Every box you could switch TO.
Each option's real name lives in its own popup rather than the grid
(the grid calls them all "Georgia Box"), so this reads them there.
The box you are already on is deliberately absent: the site offers no
"switch to this" control for it, so there is no popup and no name. Its
id shows up as every popup's `ReplaceItemID`, which is what
`current_basket_id` returns; its NAME comes from the subscription list.
"""
baskets: list[Basket] = []
seen: set[str] = set()
for group in BASKET_GROUPS:
page = await self._client.async_fetch(f"{BASKET_TYPES}/{group}")
tokens = dict.fromkeys(
re.findall(r'openPopup\("select-basket",\s*"([^"]+)"', page)
)
for token in tokens:
popup = await self._client.async_fetch(
f"/x/popup/select-basket/{token}", is_page=False
)
soup = BeautifulSoup(popup, "html.parser")
form = _find_form(soup, SUBMIT_BASKET)
if form is None:
continue # a catch-all page, not a real popup
fields = _hidden_fields(form)
item_id = fields.get("ItemID", "")
if not item_id or item_id in seen:
continue
seen.add(item_id)
heading = soup.select_one("h4, h5, h6")
baskets.append(
Basket(
item_id=item_id,
name=(heading.get_text(" ", strip=True) if heading else item_id),
is_current=False, # see the docstring: never offered
token=token,
)
)
return baskets
async def async_current_basket_id(self) -> str | None:
"""The id of the box currently subscribed, read off any switch popup."""
for group in BASKET_GROUPS:
page = await self._client.async_fetch(f"{BASKET_TYPES}/{group}")
for token in dict.fromkeys(
re.findall(r'openPopup\("select-basket",\s*"([^"]+)"', page)
):
popup = await self._client.async_fetch(
f"/x/popup/select-basket/{token}", is_page=False
)
form = _find_form(BeautifulSoup(popup, "html.parser"), SUBMIT_BASKET)
if form is not None:
return _hidden_fields(form).get("ReplaceItemID")
return None
async def async_change_basket(
self, name_or_id: str, all_future: bool = False, dry_run: bool = True
) -> ActionResult:
"""Switch to a different produce box.
`all_future=False` changes only the next delivery; True changes the
standing order. Defaulting to the one-off is deliberate — a mistaken
permanent change is the more annoying of the two to undo.
"""
wanted = str(name_or_id).strip().lower()
for basket in await self.async_list_baskets():
if wanted not in (basket.item_id.lower(), basket.name.lower()):
continue
popup = await self._client.async_fetch(
f"/x/popup/select-basket/{basket.token}", is_page=False
)
form = _find_form(BeautifulSoup(popup, "html.parser"), SUBMIT_BASKET)
if form is None:
raise FreshHarvestActionError("basket form disappeared mid-flight")
payload = _hidden_fields(form)
payload["popup-toggle"] = SCOPE_STANDING if all_future else SCOPE_ONCE
scope = "all future orders" if all_future else "the next delivery only"
result = ActionResult(
action="change_basket", ok=True, target=basket.name,
submitted=payload, dry_run=dry_run,
detail=f"switch to {basket.name} for {scope}",
)
if not dry_run:
await self._post(SUBMIT_BASKET, payload)
return result
raise FreshHarvestActionError(f"no box matches {name_or_id!r}")
class FreshHarvestActions(FreshHarvestActions, BasketMixin): # noqa: F811
"""Actions plus basket switching."""
+107
View File
@@ -0,0 +1,107 @@
"""Produce box selection.
Switching box is not adding an add-on: you change WHICH box arrives, not what
is inside it. A select fits that — one choice from a fixed set — where the
to-do list fits add-ons.
Selecting here changes the next delivery only. Changing the standing order is a
different, stickier operation and is left to the portal rather than being one
mis-click away from every future box.
"""
from __future__ import annotations
import logging
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import FreshHarvestConfigEntry
from .api import FreshHarvestError
from .const import DOMAIN, EVENT_ACTION
from .coordinator import FreshHarvestCoordinator
from .entity import FreshHarvestEntity
_LOGGER = logging.getLogger(__name__)
DESCRIPTION = SelectEntityDescription(
key="produce_box",
translation_key="produce_box",
icon="mdi:package-variant-closed",
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FreshHarvestConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the select platform."""
async_add_entities([FreshHarvestBoxSelect(entry.runtime_data, entry)])
class FreshHarvestBoxSelect(FreshHarvestEntity, SelectEntity):
"""Which produce box arrives next."""
entity_description = DESCRIPTION
def __init__(
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
) -> None:
super().__init__(coordinator, entry, DESCRIPTION.key)
self._entry = entry
self._options: list[str] = []
@property
def current_option(self) -> str | None:
"""The subscribed box.
Read from the subscription rather than the switch popups: the box you
are on is the one the site offers no switch control for, so it has no
name there.
"""
subs = self.coordinator.data.subscriptions
return subs[0].name if subs else None
@property
def options(self) -> list[str]:
"""Boxes on offer, plus whatever is current so the state is valid."""
current = self.current_option
opts = list(self._options)
if current and current not in opts:
opts.insert(0, current)
return opts
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
# Listing costs a fetch per box popup, so it happens once on setup
# rather than on every coordinator refresh.
try:
baskets = await self.coordinator.actions.async_list_baskets()
self._options = [b.name for b in baskets]
except FreshHarvestError as err:
_LOGGER.warning("could not list produce boxes: %s", err)
async def async_select_option(self, option: str) -> None:
"""Switch the next delivery to this box."""
if option == self.current_option:
return
try:
result = await self.coordinator.actions.async_change_basket(
option, all_future=False, dry_run=False
)
except FreshHarvestError as err:
self._fire(False, option, str(err))
raise HomeAssistantError(f"could not switch to {option}: {err}") from err
self._fire(True, option, result.detail)
await self.coordinator.async_request_refresh()
def _fire(self, ok: bool, target: str, detail: str) -> None:
self.hass.bus.async_fire(
EVENT_ACTION,
{"domain": DOMAIN, "entry_id": self._entry.entry_id,
"action": "change_basket", "success": ok, "target": target,
"detail": detail},
)
@@ -84,6 +84,11 @@
"add_ons": {
"name": "Add-ons"
}
},
"select": {
"produce_box": {
"name": "Produce box"
}
}
}
}
@@ -84,6 +84,11 @@
"add_ons": {
"name": "Add-ons"
}
},
"select": {
"produce_box": {
"name": "Produce box"
}
}
}
}
+1
View File
@@ -18,6 +18,7 @@ PLATFORMS = {
"binary_sensor": "binary_sensor.py",
"switch": "switch.py",
"button": "button.py",
"select": "select.py",
}