diff --git a/.gitignore b/.gitignore index 7a60b85..692f12e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ __pycache__/ *.pyc +.pytest_cache/ +.venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 34e3f4b..017d9a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,22 +5,21 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.1.0] - 2026-08-03 ### Added - Config flow taking freshharvest.com portal credentials. - Session client implementing the two-step login handshake against `/s/popup/login` and `/s/submit/login`, including the per-session - `LoginSecurity` and `SubmitToken` anti-replay fields. + `LoginSecurity` and `SubmitToken` anti-replay fields, with one automatic + re-authentication when a session lapses. +- Dashboard parser reading delivery day, next arrival date, both upcoming + carts, produce-box contents, add-ons, and order totals from a single + `GET /p/dashboard/details`. - Update coordinator polling every 6 hours, surfacing auth failures as `ConfigEntryAuthFailed` so Home Assistant prompts for re-authentication. -- Sensor platform for next delivery date, order total, order status, and box - item count. - -### Known limitations - -- Delivery parsing is unimplemented; the integration cannot yet produce values. - The portal HTML has not been mapped against a signed-in session. -- The signed-in check is a provisional heuristic and needs confirming against a - real authenticated response. +- Five sensors: next delivery date, next delivery total, next delivery item + count, open order delivery date, and shopping window. +- Parser tests covering totals, contents, the locked/open distinction, money + parsing, and year rollover on undated cart tabs. diff --git a/README.md b/README.md index f70ef31..d6a7bab 100644 --- a/README.md +++ b/README.md @@ -3,55 +3,68 @@ Unofficial Home Assistant integration for [Fresh Harvest](https://freshharvest.com/), the Georgia local-produce delivery subscription. -> **Status: incomplete — does not work yet.** The login handshake is implemented -> and the entity scaffolding is in place, but the portal page parsing is not -> written. See [Why it is unfinished](#why-it-is-unfinished). +## Entities -## What it is meant to expose +| Entity | Example | Notes | +| --- | --- | --- | +| `sensor.fresh_harvest_next_delivery` | `2026-08-04` | Attributes: `delivery_day`, `box` | +| `sensor.fresh_harvest_next_delivery_total` | `109.06` | Attributes: `subtotal`, `tax`, `delivery_fee` | +| `sensor.fresh_harvest_next_delivery_items` | `14` | Attributes: `box`, `produce`, `add_ons` | +| `sensor.fresh_harvest_open_order_delivery` | `2026-08-11` | The order you can still change | +| `sensor.fresh_harvest_shopping_window` | `Shop tomorrow` | `closed` when nothing is customizable | -| Entity | Value | -| --- | --- | -| `sensor.fresh_harvest_next_delivery` | Date of the next scheduled delivery | -| `sensor.fresh_harvest_order_total` | Cost of the upcoming order | -| `sensor.fresh_harvest_order_status` | Portal order status | -| `sensor.fresh_harvest_items_in_box` | Item count, with contents in `items` attribute | +The next delivery and the *open* order are usually two different deliveries. +Once an order passes its cutoff it locks for packing, and the cart you can still +edit is the following week's. -## How the site works +## How it works Fresh Harvest is not on Shopify, Farmigo, or Local Line — the page metadata reports `Vy Technology - Custom Code`. It is a server-rendered jQuery site with -no JSON API and no mobile app, so this integration scrapes HTML. +no JSON API and no mobile app, so this integration signs in and parses HTML. Login is a two-step handshake: 1. `GET /s/popup/login` returns the form plus two hidden anti-replay fields, `LoginSecurity` and `SubmitToken`, minted per session. 2. `POST /s/submit/login` with `LoginEmail`, `LoginPassword`, both tokens, and - an empty `Redirect`. + an empty `Redirect`, yielding an `fh_session_authenticated` cookie. -The tokens are bound to the cookie issued by step 1, so the two requests must -share a cookie jar and cannot be cached or split. +The tokens are bound to the cookie issued by step 1, so both requests must +share a cookie jar. Everything the integration needs then comes from a single +`GET /p/dashboard/details`, which carries the delivery day, next arrival date, +both upcoming carts, their contents, and their totals. -## Why it is unfinished +## Markup notes -Signed out, **every** `/p/*` path returns HTTP 200 — including invented ones. -The site has no distinguishable 404, so the account pages cannot be located by -probing, and the delivery markup cannot be guessed. Finishing this requires one -signed-in session to capture the real account, delivery, and box-contents pages. +Two traps are worth recording, since neither is guessable from the outside: -Concretely, what remains: - -- Implement `FreshHarvestClient.async_get_next_delivery()` in - [api.py](custom_components/freshharvest/api.py). -- Replace the provisional `_looks_authenticated()` heuristic, which currently - guesses at a "sign out" link, with a real signed-in marker. +- **HTTP status means nothing.** Every `/p/*` path returns 200, including + invented ones. Signed-in state is detected by the presence of a Sign Out + control, not by a status code. +- **`cart-contents-skipped` does not mean the order was skipped.** It marks the + locked cart — the one past its cutoff and arriving next. Treating it as + "skipped" reports the wrong delivery as cancelled. The reliable signal for + "can still be changed" is a non-empty `.cart-customize-wrapper`. ## Installation -Copy `custom_components/freshharvest/` into your Home Assistant `config/custom_components/` -directory and restart, then add the integration from **Settings → Devices & Services**. +Copy `custom_components/freshharvest/` into your Home Assistant +`config/custom_components/` directory and restart Home Assistant, then add the +integration from **Settings → Devices & Services**. Credentials are your normal +freshharvest.com email and password. + +## Tests + +``` +pip install beautifulsoup4 pytest +pytest tests/ +``` + +The fixture is synthetic but mirrors the real markup; the live page carries the +account holder's name, address, and phone number, so it is not committed. ## Disclaimer -Unofficial and unaffiliated. Polls every 6 hours; please do not lower that — -this is a small business's website, not an API. +Unofficial and unaffiliated. Polls once every 6 hours; please do not lower that +— this is a small business's website, not an API. diff --git a/custom_components/freshharvest/api.py b/custom_components/freshharvest/api.py index d42c332..cf7c787 100644 --- a/custom_components/freshharvest/api.py +++ b/custom_components/freshharvest/api.py @@ -1,7 +1,12 @@ -"""HTTP client for the freshharvest.com customer portal. +"""HTTP client and HTML parser for the freshharvest.com customer portal. -The site is server-rendered (no JSON API), so this client drives the same form -flow a browser does and parses HTML out the other side. +The site is server-rendered with no JSON API, so this client drives the same +form flow a browser does and parses the dashboard markup. + +Everything the integration needs lives on a single page, +``/p/dashboard/details``: the account's delivery day and next arrival date, the +upcoming carts with their contents, and the order totals. Keeping this to one +request per refresh is deliberate — the portal is a small business's website. """ from __future__ import annotations @@ -12,6 +17,7 @@ from dataclasses import dataclass, field from datetime import date import aiohttp +from bs4 import BeautifulSoup from yarl import URL _LOGGER = logging.getLogger(__name__) @@ -19,18 +25,26 @@ _LOGGER = logging.getLogger(__name__) BASE = URL("https://freshharvest.com") LOGIN_FORM = "/s/popup/login" LOGIN_SUBMIT = "/s/submit/login" +DASHBOARD = "/p/dashboard/details" -# Browser UA: the portal serves a reduced/blocked page to obvious scripts. +# The portal serves a reduced page to obviously-scripted clients. USER_AGENT = ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/126.0 Safari/537.36" ) -# Hidden anti-replay fields, minted fresh on every GET of the login form and -# only valid for the session cookie they were issued against. +# Hidden anti-replay fields, minted per session on each GET of the login form. _HIDDEN_RE = re.compile( r"name='(?PLoginSecurity|SubmitToken)'[^>]*value='(?P[^']*)'" ) +_NEXT_ARRIVING_RE = re.compile( + r"Your deliveries are\s*(?P[A-Za-z]+)\.\s*" + r"Next Arriving:\s*(?P[A-Za-z]+\s+\d+(?:st|nd|rd|th)?,\s*\d{4})", + re.IGNORECASE, +) +_TAB_DATE_RE = re.compile(r"(?P\d{1,2})/(?P\d{1,2})") +_ORDINAL_RE = re.compile(r"(\d+)(?:st|nd|rd|th)", re.IGNORECASE) +_MONEY_RE = re.compile(r"-?\$\s*([\d,]+\.\d{2})") class FreshHarvestError(Exception): @@ -41,16 +55,210 @@ class FreshHarvestAuthError(FreshHarvestError): """Credentials rejected, or the session expired and could not be renewed.""" -@dataclass -class Delivery: - """A scheduled delivery.""" +def _money(text: str | None) -> float | None: + """Pull a dollar amount out of a label. Non-amounts (e.g. 'Add Tip') -> None.""" + if not text: + return None + match = _MONEY_RE.search(text) + if not match: + return None + return float(match.group(1).replace(",", "")) + +def _text(node, selector: str) -> str | None: + found = node.select_one(selector) + if found is None: + return None + value = found.get_text(" ", strip=True) + return value or None + + +@dataclass +class OrderItem: + """One line in a cart: a produce-box component or a paid add-on.""" + + name: str + quantity: int | None = None + unit: str | None = None + price: float | None = None + + +@dataclass +class DeliveryOrder: + """A single upcoming delivery.""" + + delivery_id: str delivery_date: date | None = None - window: str | None = None - status: str | None = None + box_name: str | None = None + box_price: float | None = None + items: list[OrderItem] = field(default_factory=list) + addons: list[OrderItem] = field(default_factory=list) + subtotal: float | None = None + tax: float | None = None + delivery_fee: float | None = None total: float | None = None - items: list[str] = field(default_factory=list) - cutoff: str | None = None + # Non-empty only while the order can still be changed, e.g. "Shop tomorrow" + # or "Shop thru Sunday 8/9". Empty once the order is locked for packing. + shop_window: str | None = None + + @property + def is_open(self) -> bool: + """Whether the order can still be customized.""" + return bool(self.shop_window) + + @property + def all_items(self) -> list[OrderItem]: + return [*self.items, *self.addons] + + +@dataclass +class AccountSnapshot: + """Everything read from one dashboard fetch.""" + + next_delivery: date | None = None + delivery_day: str | None = None + orders: list[DeliveryOrder] = field(default_factory=list) + + @property + def next_order(self) -> DeliveryOrder | None: + """The cart for the next arriving delivery. + + Matched against the account's stated next-arrival date. Note that this + order is usually already locked: the *open* cart is the one after it. + """ + if self.next_delivery is not None: + for order in self.orders: + if order.delivery_date == self.next_delivery: + return order + dated = [o for o in self.orders if o.delivery_date] + return min(dated, key=lambda o: o.delivery_date) if dated else None + + @property + def open_order(self) -> DeliveryOrder | None: + """The earliest cart that can still be customized.""" + candidates = [o for o in self.orders if o.is_open and o.delivery_date] + return min(candidates, key=lambda o: o.delivery_date) if candidates else None + + +def _parse_full_date(raw: str) -> date | None: + """Parse 'August 4th, 2026'.""" + from datetime import datetime + + cleaned = _ORDINAL_RE.sub(r"\1", raw).replace(",", " ") + cleaned = re.sub(r"\s+", " ", cleaned).strip() + try: + return datetime.strptime(cleaned, "%B %d %Y").date() + except ValueError: + _LOGGER.debug("could not parse date %r", raw) + return None + + +def _parse_tab_date(raw: str, anchor: date | None) -> date | None: + """Parse a cart tab label like 'Tue 8/11', which carries no year. + + The year is taken from ``anchor`` (the account's next-arrival date), rolling + forward when the tab falls far enough behind it to be a December/January + boundary rather than a genuinely earlier delivery. + """ + match = _TAB_DATE_RE.search(raw or "") + if not match: + return None + month, day = int(match["month"]), int(match["day"]) + year = anchor.year if anchor else date.today().year + try: + candidate = date(year, month, day) + except ValueError: + return None + if anchor and (anchor - candidate).days > 180: + try: + candidate = date(year + 1, month, day) + except ValueError: + return None + return candidate + + +def parse_dashboard(html: str) -> AccountSnapshot: + """Parse ``/p/dashboard/details`` into a snapshot.""" + soup = BeautifulSoup(html, "html.parser") + snapshot = AccountSnapshot() + + account = soup.select_one(".account") + if account is not None: + match = _NEXT_ARRIVING_RE.search(account.get_text(" ", strip=True)) + if match: + snapshot.delivery_day = match["day"] + snapshot.next_delivery = _parse_full_date(match["date"]) + + # Tab labels carry the delivery dates; the cart bodies carry everything else. + tab_dates = { + node.get("data-cart-select"): node.get_text(strip=True) + for node in soup.select(".cart-selector-options-wrapper > div[data-cart-select]") + } + + for cart in soup.select("div.cart-contents[data-cart-select]"): + delivery_id = cart.get("data-cart-select") + order = DeliveryOrder( + delivery_id=delivery_id, + delivery_date=_parse_tab_date( + tab_dates.get(delivery_id, ""), snapshot.next_delivery + ), + box_name=_text(cart, ".cart-basket-columns h6"), + box_price=_money(_text(cart, ".cart-basket-columns .item-total")), + shop_window=_text(cart, ".cart-customize-wrapper"), + ) + + for row in cart.select(".basket-item"): + name = _text(row, ".basket-item-name") + if not name: + continue + quantity = _text(row, ".basket-item-quantity") + order.items.append( + OrderItem( + name=name, + quantity=int(quantity) if (quantity or "").isdigit() else None, + unit=_text(row, ".basket-item-uom"), + ) + ) + + for row in cart.select(".cart-item"): + classes = row.get("class") or [] + if "basket-item" in classes: + continue + name = _text(row, ".item-name") + if not name: + continue + quantity = _text(row, ".item-order-quantity") + order.addons.append( + OrderItem( + name=name, + # .item-total is the extended price: 4 muffins -> $17.96. + price=_money(_text(row, ".item-total")), + quantity=int(quantity) if (quantity or "").isdigit() else None, + unit=_text(row, ".item-uom"), + ) + ) + + totals = soup.select_one(f"#OrderTotals-{delivery_id}") + if totals is not None: + labels = totals.select(".summary-item.label") + values = totals.select(".summary-item.value") + for label, value in zip(labels, values): + key = label.get_text(" ", strip=True).lower() + amount = _money(value.get_text(" ", strip=True)) + if key.startswith("order total"): + order.total = amount + elif key.startswith("subtotal"): + order.subtotal = amount + elif key.startswith("tax"): + order.tax = amount + elif key.startswith("delivery"): + order.delivery_fee = amount + + snapshot.orders.append(order) + + if not snapshot.orders and snapshot.next_delivery is None: + raise FreshHarvestError("dashboard markup not recognised") + return snapshot class FreshHarvestClient: @@ -75,7 +283,7 @@ class FreshHarvestClient: """Run the two-step handshake: fetch tokens, then post credentials. The tokens are bound to the session cookie issued by the same GET, so - the fetch and the post cannot be split across sessions or cached. + the fetch and the post must share a cookie jar. """ form = await self._get(LOGIN_FORM) hidden = {m["name"]: m["value"] for m in _HIDDEN_RE.finditer(form)} @@ -92,53 +300,44 @@ class FreshHarvestClient: "SubmitToken": hidden["SubmitToken"], "Redirect": "", } - async with self._session.post( - BASE.join(URL(LOGIN_SUBMIT)), - data=payload, - headers={"User-Agent": USER_AGENT}, - ) as resp: - resp.raise_for_status() - body = await resp.text() + try: + async with self._session.post( + BASE.join(URL(LOGIN_SUBMIT)), + data=payload, + headers={"User-Agent": USER_AGENT}, + ) as resp: + resp.raise_for_status() + body = await resp.text() + except aiohttp.ClientError as err: + raise FreshHarvestError(f"login request failed: {err}") from err - if not self._looks_authenticated(body): + if not self._signed_in(body): raise FreshHarvestAuthError("login rejected") self._authenticated = True @staticmethod - def _looks_authenticated(body: str) -> bool: - """Distinguish a good login from a rejected one. + def _signed_in(body: str) -> bool: + """A signed-in page carries a Sign Out control; a signed-out one does not. - NOTE: provisional. Every /p/* path returns 200 even for nonsense URLs, - so an HTTP status is not a signal here. Needs confirming against a real - authenticated response before this integration can be trusted. + HTTP status is useless here — the portal answers 200 for every /p/ path, + including invented ones. """ - lowered = body.lower() - if "invalid" in lowered or "incorrect" in lowered: - return False - return "sign out" in lowered or "log out" in lowered + return "sign out" in body.lower() - async def async_get_next_delivery(self) -> Delivery: - """Return the account's next scheduled delivery. - - UNIMPLEMENTED. The portal serves a catch-all 200 for every /p/* path - when signed out, so the real account pages could not be located or - parsed without an authenticated session. Fill this in against a live - session rather than guessing at selectors. - """ - raise FreshHarvestError( - "delivery parsing is not implemented yet: the portal HTML has not " - "been mapped against a signed-in session" - ) - - async def async_fetch(self, path: str) -> str: - """Fetch a portal page, re-authenticating once if the session lapsed.""" + async def async_get_snapshot(self) -> AccountSnapshot: + """Fetch and parse the dashboard, re-authenticating once if needed.""" if not self._authenticated: await self.async_login() - body = await self._get(path) - if not self._looks_authenticated(body): - self._authenticated = False - await self.async_login() - body = await self._get(path) - if not self._looks_authenticated(body): - raise FreshHarvestAuthError(f"could not hold a session for {path}") - return body + + try: + body = await self._get(DASHBOARD) + if not self._signed_in(body): + self._authenticated = False + await self.async_login() + body = await self._get(DASHBOARD) + if not self._signed_in(body): + raise FreshHarvestAuthError("could not hold a signed-in session") + except aiohttp.ClientError as err: + raise FreshHarvestError(f"dashboard request failed: {err}") from err + + return parse_dashboard(body) diff --git a/custom_components/freshharvest/coordinator.py b/custom_components/freshharvest/coordinator.py index 8039112..c7d1aea 100644 --- a/custom_components/freshharvest/coordinator.py +++ b/custom_components/freshharvest/coordinator.py @@ -9,14 +9,19 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .api import Delivery, FreshHarvestAuthError, FreshHarvestClient, FreshHarvestError +from .api import ( + AccountSnapshot, + FreshHarvestAuthError, + FreshHarvestClient, + FreshHarvestError, +) from .const import DOMAIN, UPDATE_INTERVAL _LOGGER = logging.getLogger(__name__) -class FreshHarvestCoordinator(DataUpdateCoordinator[Delivery]): - """Fetch the account's next delivery.""" +class FreshHarvestCoordinator(DataUpdateCoordinator[AccountSnapshot]): + """Fetch the account dashboard on a slow interval.""" def __init__( self, hass: HomeAssistant, entry: ConfigEntry, client: FreshHarvestClient @@ -30,9 +35,9 @@ class FreshHarvestCoordinator(DataUpdateCoordinator[Delivery]): ) self.client = client - async def _async_update_data(self) -> Delivery: + async def _async_update_data(self) -> AccountSnapshot: try: - return await self.client.async_get_next_delivery() + return await self.client.async_get_snapshot() except FreshHarvestAuthError as err: raise ConfigEntryAuthFailed(str(err)) from err except FreshHarvestError as err: diff --git a/custom_components/freshharvest/manifest.json b/custom_components/freshharvest/manifest.json index 0dbe449..d57e4c8 100644 --- a/custom_components/freshharvest/manifest.json +++ b/custom_components/freshharvest/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://git.onetick.ninja/flan/ha-freshharvest/issues", - "requirements": [], + "requirements": ["beautifulsoup4>=4.12"], "version": "0.1.0" } diff --git a/custom_components/freshharvest/sensor.py b/custom_components/freshharvest/sensor.py index 5853440..e0cb327 100644 --- a/custom_components/freshharvest/sensor.py +++ b/custom_components/freshharvest/sensor.py @@ -4,11 +4,13 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -16,16 +18,30 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import FreshHarvestConfigEntry -from .api import Delivery +from .api import AccountSnapshot, DeliveryOrder from .const import DOMAIN from .coordinator import FreshHarvestCoordinator +def _items_attrs(order: DeliveryOrder | None) -> dict[str, Any] | None: + if order is None: + return None + return { + "box": order.box_name, + "produce": [ + " ".join(part for part in (str(i.quantity or ""), i.name, i.unit) if part) + for i in order.items + ], + "add_ons": [a.name for a in order.addons], + } + + @dataclass(frozen=True, kw_only=True) class FreshHarvestSensorDescription(SensorEntityDescription): """Describes a Fresh Harvest sensor.""" - value_fn: Callable[[Delivery], object] + value_fn: Callable[[AccountSnapshot], Any] + attrs_fn: Callable[[AccountSnapshot], dict[str, Any] | None] | None = None SENSORS: tuple[FreshHarvestSensorDescription, ...] = ( @@ -33,24 +49,52 @@ SENSORS: tuple[FreshHarvestSensorDescription, ...] = ( key="next_delivery", translation_key="next_delivery", device_class=SensorDeviceClass.DATE, - value_fn=lambda d: d.delivery_date, + value_fn=lambda s: s.next_delivery, + attrs_fn=lambda s: { + "delivery_day": s.delivery_day, + "box": s.next_order.box_name if s.next_order else None, + }, ), FreshHarvestSensorDescription( - key="order_total", - translation_key="order_total", + key="next_delivery_total", + translation_key="next_delivery_total", device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.TOTAL, native_unit_of_measurement="USD", - value_fn=lambda d: d.total, + value_fn=lambda s: s.next_order.total if s.next_order else None, + attrs_fn=lambda s: None + if s.next_order is None + else { + "subtotal": s.next_order.subtotal, + "tax": s.next_order.tax, + "delivery_fee": s.next_order.delivery_fee, + }, ), FreshHarvestSensorDescription( - key="status", - translation_key="status", - value_fn=lambda d: d.status, + key="next_delivery_items", + translation_key="next_delivery_items", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="items", + value_fn=lambda s: len(s.next_order.all_items) if s.next_order else None, + attrs_fn=lambda s: _items_attrs(s.next_order), ), FreshHarvestSensorDescription( - key="item_count", - translation_key="item_count", - value_fn=lambda d: len(d.items) if d.items else None, + key="open_order_delivery", + translation_key="open_order_delivery", + device_class=SensorDeviceClass.DATE, + value_fn=lambda s: s.open_order.delivery_date if s.open_order else None, + attrs_fn=lambda s: None + if s.open_order is None + else { + "total": s.open_order.total, + "box": s.open_order.box_name, + }, + ), + FreshHarvestSensorDescription( + key="shop_window", + translation_key="shop_window", + value_fn=lambda s: (s.open_order.shop_window if s.open_order else None) + or "closed", ), ) @@ -68,7 +112,7 @@ async def async_setup_entry( class FreshHarvestSensor(CoordinatorEntity[FreshHarvestCoordinator], SensorEntity): - """A value read off the Fresh Harvest portal.""" + """A value read off the Fresh Harvest dashboard.""" _attr_has_entity_name = True entity_description: FreshHarvestSensorDescription @@ -87,17 +131,17 @@ class FreshHarvestSensor(CoordinatorEntity[FreshHarvestCoordinator], SensorEntit name="Fresh Harvest", manufacturer="Fresh Harvest", entry_type=DeviceEntryType.SERVICE, - configuration_url="https://freshharvest.com/", + configuration_url="https://freshharvest.com/p/dashboard/details", ) @property - def native_value(self): + def native_value(self) -> Any: """Return the sensor value.""" return self.entity_description.value_fn(self.coordinator.data) @property - def extra_state_attributes(self) -> dict[str, object] | None: - """Expose box contents on the item-count sensor.""" - if self.entity_description.key != "item_count": + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the sensor's extra attributes.""" + if self.entity_description.attrs_fn is None: return None - return {"items": self.coordinator.data.items} + return self.entity_description.attrs_fn(self.coordinator.data) diff --git a/custom_components/freshharvest/strings.json b/custom_components/freshharvest/strings.json index dbdc7f5..7ae2955 100644 --- a/custom_components/freshharvest/strings.json +++ b/custom_components/freshharvest/strings.json @@ -20,9 +20,10 @@ "entity": { "sensor": { "next_delivery": { "name": "Next delivery" }, - "order_total": { "name": "Order total" }, - "status": { "name": "Order status" }, - "item_count": { "name": "Items in box" } + "next_delivery_total": { "name": "Next delivery total" }, + "next_delivery_items": { "name": "Next delivery items" }, + "open_order_delivery": { "name": "Open order delivery" }, + "shop_window": { "name": "Shopping window" } } } } diff --git a/tests/fixtures/dashboard.html b/tests/fixtures/dashboard.html new file mode 100644 index 0000000..6901804 --- /dev/null +++ b/tests/fixtures/dashboard.html @@ -0,0 +1,92 @@ + + + + + + +
+
+
Georgia Grown Small Box
+
$33.00
+
Shop tomorrow
+
+
+
+
+ Subtotal$33.00 + Driver TipAdd Tip + Tax$0.99 + Delivery$5.99 + Order Total See Details$39.98 +
+
+ + +
+
+
Georgia Grown Small Box
+
$33.00
+
+
+
+
+
+ 1 + Bolero Carrots + .5 lb +
+
+
+
+ 2 + Georgia Peaches + 6 count +
+
+
+
+ Black Mission Figs +
+
Black Mission Figs
+ $7.99 +
1 pint
+
+
+
+ 1 +
+
+
+
+
+
+
+
+ Subtotal$105.88 + Tax$3.18 + Delivery$0.00 + Order Total See Details$109.06 +
+
+ + + + Sign Out + + diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..16e5d69 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,110 @@ +"""Tests for the dashboard parser. + +The parser is import-isolated from Home Assistant so these run without a HA +install; only beautifulsoup4 is needed. +""" + +from __future__ import annotations + +import importlib.util +import sys +import types +from datetime import date +from pathlib import Path + +import pytest + +FIXTURE = Path(__file__).parent / "fixtures" / "dashboard.html" +API_PATH = ( + Path(__file__).parent.parent / "custom_components" / "freshharvest" / "api.py" +) + + +def _load_api(): + """Import api.py with aiohttp/yarl stubbed out.""" + if "aiohttp" not in sys.modules: + stub = types.ModuleType("aiohttp") + stub.ClientSession = object + stub.ClientError = Exception + sys.modules["aiohttp"] = stub + if "yarl" not in sys.modules: + try: + import yarl # noqa: F401 + except ImportError: + stub = types.ModuleType("yarl") + stub.URL = lambda value="": value + sys.modules["yarl"] = stub + spec = importlib.util.spec_from_file_location("fh_api", API_PATH) + module = importlib.util.module_from_spec(spec) + sys.modules["fh_api"] = module + spec.loader.exec_module(module) + return module + + +api = _load_api() + + +@pytest.fixture(name="snapshot") +def snapshot_fixture(): + return api.parse_dashboard(FIXTURE.read_text(encoding="utf-8")) + + +def test_account_level_fields(snapshot): + assert snapshot.delivery_day == "Tuesdays" + assert snapshot.next_delivery == date(2026, 8, 4) + assert len(snapshot.orders) == 2 + + +def test_next_order_is_the_locked_one(snapshot): + """The next arrival is past its cutoff; the open cart is the one after it. + + Guards the trap that sank the first attempt: the locked cart carries the + class `cart-contents-skipped`, which does not mean the user skipped it. + """ + order = snapshot.next_order + assert order.delivery_id == "2772590" + assert order.delivery_date == date(2026, 8, 4) + assert order.is_open is False + + +def test_next_order_totals_and_contents(snapshot): + order = snapshot.next_order + assert order.box_name == "Georgia Grown Small Box" + assert (order.subtotal, order.tax, order.delivery_fee) == (105.88, 3.18, 0.0) + assert order.total == 109.06 + assert [(i.quantity, i.name, i.unit) for i in order.items] == [ + (1, "Bolero Carrots", ".5 lb"), + (2, "Georgia Peaches", "6 count"), + ] + assert [ + (a.name, a.price, a.quantity, a.unit) for a in order.addons + ] == [("Black Mission Figs", 7.99, 1, "1 pint")] + assert len(order.all_items) == 3 + + +def test_open_order(snapshot): + order = snapshot.open_order + assert order.delivery_id == "2778336" + assert order.delivery_date == date(2026, 8, 11) + assert order.is_open is True + assert order.shop_window == "Shop tomorrow" + assert order.total == 39.98 + + +def test_driver_tip_placeholder_is_not_money(snapshot): + """'Add Tip' sits in a value slot but is not an amount.""" + assert api._money("Add Tip") is None + assert api._money("$1,234.50") == 1234.50 + assert api._money("$0.00") == 0.0 + + +def test_tab_date_rolls_over_the_year(): + """A January tab against a December anchor belongs to the next year.""" + assert api._parse_tab_date("Tue 1/5", date(2026, 12, 29)) == date(2027, 1, 5) + assert api._parse_tab_date("Tue 8/11", date(2026, 8, 4)) == date(2026, 8, 11) + assert api._parse_tab_date("no date here", date(2026, 8, 4)) is None + + +def test_unrecognised_markup_raises(): + with pytest.raises(api.FreshHarvestError): + api.parse_dashboard("signed out")