Add control entities: box to-do list, skip switch, donate button, subscriptions
Validate / hassfest (push) Failing after 6s
Validate / pytest (push) Failing after 8s
Validate / HACS (push) Failing after 1m8s

The to-do list exists so Home Assistant's own conversation agent can manage the
order through HassListAddItem rather than through anything bespoke. Skip is a
switch because skipped/not-skipped is state worth reading back; donate is a
button because it cannot be undone. Un-skip raises rather than guessing at an
endpoint that has never been observed.

Fixes subscription parsing, which matched the heading row and so reported zero
on an account that has one.
This commit is contained in:
flan
2026-08-03 20:13:32 +00:00
parent 213c16d98e
commit 551d3240a2
14 changed files with 592 additions and 14 deletions
+7 -1
View File
@@ -10,7 +10,13 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .api import FreshHarvestClient
from .coordinator import FreshHarvestCoordinator
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.SENSOR,
Platform.SWITCH,
Platform.TODO,
]
type FreshHarvestConfigEntry = ConfigEntry[FreshHarvestCoordinator]
+25 -11
View File
@@ -112,25 +112,39 @@ def _find_form(soup: BeautifulSoup, action: str):
def parse_subscriptions(html: str) -> list[Subscription]:
"""Read /p/dashboard/manage-subscriptions."""
"""Read /p/dashboard/manage-subscriptions.
Cells are picked by their semantic class rather than column position:
`.account-item-multi-fields` is the HEADING row, and matching on it
silently yields zero subscriptions on an account that has some.
"""
soup = BeautifulSoup(html, "html.parser")
account = soup.select_one(".account")
if account is None:
return []
def cell(row, *classes) -> str | None:
for cls in classes:
found = row.select_one(f".account-item-text.{cls}")
if found is not None:
text = found.get_text(" ", strip=True)
if text:
return text
return None
subs: list[Subscription] = []
for row in account.select(".account-item-multi-fields"):
cells = [c.get_text(" ", strip=True) for c in row.select(".account-item-text")]
cells = [c for c in cells if c]
if len(cells) < 2:
for row in account.select(".account-item-container"):
name = cell(row, "account-item-description")
if not name:
continue
qty = cells[0]
qty = cell(row, "account-item-history-qty")
subs.append(
Subscription(
name=cells[1],
quantity=int(qty) if qty.isdigit() else None,
arriving=cells[2] if len(cells) > 2 else None,
partner=cells[3] if len(cells) > 3 else None,
frequency=cells[4] if len(cells) > 4 else None,
name=name,
quantity=int(qty) if (qty or "").isdigit() else None,
arriving=cell(row, "account-item-history"),
partner=cell(row, "account-item-history-vendor"),
frequency=cell(row, "center"),
)
)
return subs
+4
View File
@@ -135,6 +135,10 @@ class AccountSnapshot:
# have not reached it, so it is read once and applied to every order.
free_delivery_threshold: float | None = None
orders: list[DeliveryOrder] = field(default_factory=list)
# Populated by the coordinator from separate pages. Typed loosely because
# actions.py imports this module, so it cannot be imported back from here.
subscriptions: list = field(default_factory=list)
vacation_holds: list = field(default_factory=list)
@property
def next_order(self) -> DeliveryOrder | None:
+67
View File
@@ -0,0 +1,67 @@
"""One-way delivery actions.
Donating is a button rather than a switch because it genuinely cannot be undone
from the portal — there is no state to toggle back.
"""
from __future__ import annotations
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
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
DESCRIPTION = ButtonEntityDescription(
key="donate_open_order",
translation_key="donate_open_order",
icon="mdi:hand-heart",
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FreshHarvestConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the button platform."""
async_add_entities([FreshHarvestDonate(entry.runtime_data, entry)])
class FreshHarvestDonate(FreshHarvestEntity, ButtonEntity):
"""Donate the upcoming box to Share the Harvest."""
entity_description = DESCRIPTION
def __init__(
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
) -> None:
super().__init__(coordinator, entry, DESCRIPTION.key)
self._entry = entry
@property
def available(self) -> bool:
return super().available and self.coordinator.data.open_order is not None
async def async_press(self) -> None:
"""Donate. Not reversible."""
try:
result = await self.coordinator.actions.async_donate(dry_run=False)
except FreshHarvestError as err:
self._fire(False, str(err))
raise HomeAssistantError(f"could not donate: {err}") from err
self._fire(True, result.detail)
await self.coordinator.async_request_refresh()
def _fire(self, ok: bool, detail: str) -> None:
self.hass.bus.async_fire(
EVENT_ACTION,
{"domain": DOMAIN, "entry_id": self._entry.entry_id,
"action": "donate", "success": ok, "target": "open order",
"detail": detail},
)
+4
View File
@@ -10,3 +10,7 @@ DOMAIN = "freshharvest"
# schedules change on the order of days, and the customization cutoff is the
# only time-sensitive value.
UPDATE_INTERVAL = timedelta(hours=6)
# Fired after every write action so automations can notify on success or
# failure. Data: domain, entry_id, action, success, target, detail.
EVENT_ACTION = "freshharvest_action"
@@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .actions import FreshHarvestActions
from .api import (
AccountSnapshot,
FreshHarvestAuthError,
@@ -34,10 +35,15 @@ class FreshHarvestCoordinator(DataUpdateCoordinator[AccountSnapshot]):
config_entry=entry,
)
self.client = client
self.actions = FreshHarvestActions(client)
async def _async_update_data(self) -> AccountSnapshot:
try:
return await self.client.async_get_snapshot()
snapshot = await self.client.async_get_snapshot()
# Two extra pages per refresh, so three requests every six hours.
snapshot.subscriptions = await self.actions.async_list_subscriptions()
snapshot.vacation_holds = await self.actions.async_list_vacation_holds()
return snapshot
except FreshHarvestAuthError as err:
raise ConfigEntryAuthFailed(str(err)) from err
except FreshHarvestError as err:
+32
View File
@@ -93,6 +93,38 @@ SENSORS: tuple[FreshHarvestSensorDescription, ...] = (
"free_delivery_threshold": s.free_delivery_threshold,
},
),
FreshHarvestSensorDescription(
key="subscriptions",
translation_key="subscriptions",
scope="account",
icon="mdi:autorenew",
native_unit_of_measurement="items",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda s: len(s.subscriptions),
attrs_fn=lambda s: {
"items": [
" ".join(
p for p in (str(sub.quantity or ""), sub.name,
f"({sub.frequency})" if sub.frequency else "")
if p
)
for sub in s.subscriptions
],
"partners": sorted({sub.partner for sub in s.subscriptions if sub.partner}),
},
),
FreshHarvestSensorDescription(
key="vacation_holds",
translation_key="vacation_holds",
scope="account",
icon="mdi:airplane",
native_unit_of_measurement="holds",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda s: len(s.vacation_holds),
attrs_fn=lambda s: {
"ranges": [f"{h.start} to {h.end}" for h in s.vacation_holds]
},
),
FreshHarvestSensorDescription(
key="delivery_day",
translation_key="delivery_day",
@@ -57,12 +57,33 @@
},
"shop_window": {
"name": "Shopping window"
},
"subscriptions": {
"name": "Subscriptions"
},
"vacation_holds": {
"name": "Vacation holds"
}
},
"binary_sensor": {
"order_open": {
"name": "Order open"
}
},
"switch": {
"skip_open_order": {
"name": "Skip next order"
}
},
"button": {
"donate_open_order": {
"name": "Donate next order"
}
},
"todo": {
"box": {
"name": "Box"
}
}
}
}
+96
View File
@@ -0,0 +1,96 @@
"""Skip control for the upcoming delivery.
A switch rather than a button: skipped/not-skipped is state the conversation
agent can read back and reverse, where a button would be fire-and-forget.
"""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
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 = SwitchEntityDescription(
key="skip_open_order",
translation_key="skip_open_order",
icon="mdi:calendar-remove",
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FreshHarvestConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the switch platform."""
async_add_entities([FreshHarvestSkip(entry.runtime_data, entry)])
class FreshHarvestSkip(FreshHarvestEntity, SwitchEntity):
"""On means the next changeable delivery is skipped."""
entity_description = DESCRIPTION
def __init__(
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
) -> None:
super().__init__(coordinator, entry, DESCRIPTION.key)
self._entry = entry
@property
def available(self) -> bool:
"""Only meaningful while there is an order that can still be changed."""
return super().available and self.coordinator.data.open_order is not None
@property
def is_on(self) -> bool:
"""A skipped order stops offering a shopping window."""
order = self.coordinator.data.open_order
return order is not None and not order.is_open
async def async_turn_on(self, **kwargs: Any) -> None:
"""Skip the next changeable delivery."""
order = self.coordinator.data.open_order
if order is None or order.delivery_date is None:
raise HomeAssistantError("no delivery is currently skippable")
try:
result = await self.coordinator.actions.async_skip(
order.delivery_date, dry_run=False
)
except FreshHarvestError as err:
self._fire("skip", False, str(order.delivery_date), str(err))
raise HomeAssistantError(f"could not skip: {err}") from err
self._fire("skip", True, str(order.delivery_date), result.detail)
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."
)
def _fire(self, action: str, ok: bool, target: str, detail: str) -> None:
self.hass.bus.async_fire(
EVENT_ACTION,
{"domain": DOMAIN, "entry_id": self._entry.entry_id, "action": action,
"success": ok, "target": target, "detail": detail},
)
+172
View File
@@ -0,0 +1,172 @@
"""The upcoming box as a to-do list.
This 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
`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
import json
import logging
import re
import urllib.parse
from homeassistant.components.todo import (
TodoItem,
TodoItemStatus,
TodoListEntity,
TodoListEntityFeature,
)
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__)
ALGOLIA_JS = "/_home/JavaScript/search_algolia.js"
_CREDS_RE = re.compile(r'algoliasearch\(\s*"([^"]+)"\s*,\s*"([^"]+)"', re.S)
_INDEX_RE = re.compile(r'indexName:\s*"([^"]+)"')
async def async_setup_entry(
hass: HomeAssistant,
entry: FreshHarvestConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the to-do platform."""
async_add_entities([FreshHarvestBox(entry.runtime_data, entry)])
class FreshHarvestBox(FreshHarvestEntity, TodoListEntity):
"""Everything in the upcoming delivery, as a list."""
_attr_translation_key = "box"
_attr_supported_features = (
TodoListEntityFeature.CREATE_TODO_ITEM
| TodoListEntityFeature.DELETE_TODO_ITEM
)
def __init__(
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
) -> None:
super().__init__(coordinator, entry, "box")
self._entry = entry
@property
def todo_items(self) -> list[TodoItem] | None:
"""The open order's contents, or the next delivery's once it locks."""
snapshot = self.coordinator.data
order = snapshot.open_order or snapshot.next_order
if order is None:
return None
items: list[TodoItem] = []
for item in order.all_items:
label = " ".join(
p for p in (str(item.quantity or ""), item.name, item.unit) if p
)
if item.price is not None:
label = f"{label} — ${item.price:,.2f}"
items.append(
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.
status=TodoItemStatus.NEEDS_ACTION,
)
)
return items
async def _algolia_lookup(self, query: str) -> tuple[str, str]:
"""Resolve a spoken name to (item_id, item_name) via the site's index.
Credentials are read from the site's own JS rather than hardcoded, so a
key rotation fixes itself and a third party's key never enters this repo.
"""
client = self.coordinator.client
js = await client.async_fetch(ALGOLIA_JS)
creds, index = _CREDS_RE.search(js), _INDEX_RE.search(js)
if not (creds and index):
raise HomeAssistantError(
"could not read the catalogue configuration from the site"
)
app, key = creds.groups()
payload = json.dumps(
{"params": urllib.parse.urlencode({"query": query, "hitsPerPage": 5})}
).encode()
session = client._session # noqa: SLF001 — same package
async with session.post(
f"https://{app}-dsn.algolia.net/1/indexes/{index.group(1)}/query",
data=payload,
headers={
"X-Algolia-API-Key": key,
"X-Algolia-Application-Id": app,
"Content-Type": "application/json",
},
) as resp:
resp.raise_for_status()
hits = (await resp.json()).get("hits") or []
if not hits:
raise HomeAssistantError(f"nothing in the catalogue matches {query!r}")
return str(hits[0]["ID"]), str(hits[0].get("Name") or query)
async def async_create_todo_item(self, item: TodoItem) -> None:
"""Add an item to the order.
The site only renders an add control for something orderable right now,
so an out-of-stock item fails here rather than silently doing nothing.
"""
query = (item.summary or "").strip()
if not query:
raise HomeAssistantError("no item name given")
item_id, name = await self._algolia_lookup(query)
try:
result = await self.coordinator.actions.async_add_item(
item_id, dry_run=False
)
except FreshHarvestError as err:
self._fire(EVENT_ACTION, "add_item", False, query, str(err))
raise HomeAssistantError(f"could not add {name}: {err}") from err
self._fire(EVENT_ACTION, "add_item", True, name, result.detail)
await self.coordinator.async_request_refresh()
async def async_delete_todo_items(self, uids: list[str]) -> None:
"""Take items back out of the order."""
for uid in uids:
item_id, name = await self._algolia_lookup(uid)
try:
await self.coordinator.actions.async_remove_item(
item_id, dry_run=False
)
except FreshHarvestError as err:
self._fire(EVENT_ACTION, "remove_item", False, uid, str(err))
raise HomeAssistantError(f"could not remove {name}: {err}") from err
self._fire(EVENT_ACTION, "remove_item", True, name, "removed")
await self.coordinator.async_request_refresh()
def _fire(self, event: str, action: str, ok: bool, target: str, detail: str) -> None:
"""Announce the outcome so automations can notify on it."""
self.hass.bus.async_fire(
event,
{
"domain": DOMAIN,
"entry_id": self._entry.entry_id,
"action": action,
"success": ok,
"target": target,
"detail": detail,
},
)
@@ -57,12 +57,33 @@
},
"shop_window": {
"name": "Shopping window"
},
"subscriptions": {
"name": "Subscriptions"
},
"vacation_holds": {
"name": "Vacation holds"
}
},
"binary_sensor": {
"order_open": {
"name": "Order open"
}
},
"switch": {
"skip_open_order": {
"name": "Skip next order"
}
},
"button": {
"donate_open_order": {
"name": "Donate next order"
}
},
"todo": {
"box": {
"name": "Box"
}
}
}
}