Wire produce-box switching as a select entity
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:
@@ -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
|
`todo.fresh_harvest_add_ons`; box contents stay read-only on
|
||||||
`sensor.*_next_delivery_items`.
|
`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
|
### Known gaps
|
||||||
|
|
||||||
- Entities are deliberately NOT exposed to the conversation agent yet.
|
- Entities are deliberately NOT exposed to the conversation agent yet.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from .coordinator import FreshHarvestCoordinator
|
|||||||
PLATFORMS: list[Platform] = [
|
PLATFORMS: list[Platform] = [
|
||||||
Platform.BINARY_SENSOR,
|
Platform.BINARY_SENSOR,
|
||||||
Platform.BUTTON,
|
Platform.BUTTON,
|
||||||
|
Platform.SELECT,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
Platform.TODO,
|
Platform.TODO,
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ SUBMIT_RESTORE = "/s/submit/restore-delivery"
|
|||||||
SUBMIT_DONATE = "/s/submit/donate-basket"
|
SUBMIT_DONATE = "/s/submit/donate-basket"
|
||||||
SUBMIT_SUBSCRIBE = "/s/submit/item-frequency"
|
SUBMIT_SUBSCRIBE = "/s/submit/item-frequency"
|
||||||
SUBMIT_HOLD = "/s/submit/pause-range-add"
|
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}"
|
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
|
# 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.
|
submit and this raises rather than inventing one.
|
||||||
"""
|
"""
|
||||||
page = await self._client.async_fetch(DASHBOARD_ORDERS)
|
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:
|
if not tokens:
|
||||||
raise FreshHarvestActionError("no skippable delivery on this account")
|
raise FreshHarvestActionError("no skippable delivery on this account")
|
||||||
|
|
||||||
@@ -267,7 +277,7 @@ class FreshHarvestActions:
|
|||||||
raises when there is nothing to restore.
|
raises when there is nothing to restore.
|
||||||
"""
|
"""
|
||||||
page = await self._client.async_fetch(DASHBOARD_ORDERS)
|
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:
|
if not tokens:
|
||||||
raise FreshHarvestActionError("no skipped delivery to restore")
|
raise FreshHarvestActionError("no skipped delivery to restore")
|
||||||
|
|
||||||
@@ -299,7 +309,7 @@ class FreshHarvestActions:
|
|||||||
async def async_donate(self, dry_run: bool = True) -> ActionResult:
|
async def async_donate(self, dry_run: bool = True) -> ActionResult:
|
||||||
"""Donate the upcoming box. One-way — there is no undo in the UI."""
|
"""Donate the upcoming box. One-way — there is no undo in the UI."""
|
||||||
page = await self._client.async_fetch(DASHBOARD_ORDERS)
|
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:
|
if not m:
|
||||||
raise FreshHarvestActionError("no donatable delivery")
|
raise FreshHarvestActionError("no donatable delivery")
|
||||||
popup = await self._client.async_fetch(
|
popup = await self._client.async_fetch(
|
||||||
@@ -441,3 +451,116 @@ class FreshHarvestActions:
|
|||||||
|
|
||||||
async def async_list_vacation_holds(self) -> list[VacationHold]:
|
async def async_list_vacation_holds(self) -> list[VacationHold]:
|
||||||
return parse_vacation_holds(await self._client.async_fetch(DASHBOARD_PAUSE))
|
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."""
|
||||||
|
|||||||
@@ -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": {
|
"add_ons": {
|
||||||
"name": "Add-ons"
|
"name": "Add-ons"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"produce_box": {
|
||||||
|
"name": "Produce box"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,11 @@
|
|||||||
"add_ons": {
|
"add_ons": {
|
||||||
"name": "Add-ons"
|
"name": "Add-ons"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"produce_box": {
|
||||||
|
"name": "Produce box"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ PLATFORMS = {
|
|||||||
"binary_sensor": "binary_sensor.py",
|
"binary_sensor": "binary_sensor.py",
|
||||||
"switch": "switch.py",
|
"switch": "switch.py",
|
||||||
"button": "button.py",
|
"button": "button.py",
|
||||||
|
"select": "select.py",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user