From e7427082e3dac0dbf21b6e50b474188a87593527 Mon Sep 17 00:00:00 2001 From: flan Date: Mon, 3 Aug 2026 20:31:13 +0000 Subject: [PATCH] Wire produce-box switching as a select entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 5 + custom_components/freshharvest/__init__.py | 1 + custom_components/freshharvest/actions.py | 129 +++++++++++++++++- custom_components/freshharvest/select.py | 107 +++++++++++++++ custom_components/freshharvest/strings.json | 5 + .../freshharvest/translations/en.json | 5 + tests/test_translations.py | 1 + 7 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 custom_components/freshharvest/select.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ee1c281..f0f56f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/custom_components/freshharvest/__init__.py b/custom_components/freshharvest/__init__.py index 6d99036..b803b0e 100644 --- a/custom_components/freshharvest/__init__.py +++ b/custom_components/freshharvest/__init__.py @@ -13,6 +13,7 @@ from .coordinator import FreshHarvestCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.TODO, diff --git a/custom_components/freshharvest/actions.py b/custom_components/freshharvest/actions.py index ac6ba42..6d4391e 100644 --- a/custom_components/freshharvest/actions.py +++ b/custom_components/freshharvest/actions.py @@ -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.""" diff --git a/custom_components/freshharvest/select.py b/custom_components/freshharvest/select.py new file mode 100644 index 0000000..3f79dc8 --- /dev/null +++ b/custom_components/freshharvest/select.py @@ -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}, + ) diff --git a/custom_components/freshharvest/strings.json b/custom_components/freshharvest/strings.json index e890347..3f8c8d8 100644 --- a/custom_components/freshharvest/strings.json +++ b/custom_components/freshharvest/strings.json @@ -84,6 +84,11 @@ "add_ons": { "name": "Add-ons" } + }, + "select": { + "produce_box": { + "name": "Produce box" + } } } } diff --git a/custom_components/freshharvest/translations/en.json b/custom_components/freshharvest/translations/en.json index e890347..3f8c8d8 100644 --- a/custom_components/freshharvest/translations/en.json +++ b/custom_components/freshharvest/translations/en.json @@ -84,6 +84,11 @@ "add_ons": { "name": "Add-ons" } + }, + "select": { + "produce_box": { + "name": "Produce box" + } } } } diff --git a/tests/test_translations.py b/tests/test_translations.py index 5229381..6022cc6 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -18,6 +18,7 @@ PLATFORMS = { "binary_sensor": "binary_sensor.py", "switch": "switch.py", "button": "button.py", + "select": "select.py", }