Implement dashboard parsing and delivery sensors

Reads delivery day, next arrival date, both upcoming carts, box contents,
add-ons and order totals from a single /p/dashboard/details fetch. Adds five
sensors and parser tests over a synthetic fixture mirroring the live markup.

Records two portal quirks: every /p/ path returns 200 so signed-in state is
detected by a Sign Out control, and cart-contents-skipped marks the locked
cart rather than a skipped order.
This commit is contained in:
flan
2026-08-03 17:23:35 +00:00
parent eb50a9ecc1
commit e1d8ba720c
10 changed files with 588 additions and 123 deletions
+2
View File
@@ -1,2 +1,4 @@
__pycache__/
*.pyc
.pytest_cache/
.venv/
+10 -11
View File
@@ -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.
+43 -30
View File
@@ -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.
+253 -54
View File
@@ -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='(?P<name>LoginSecurity|SubmitToken)'[^>]*value='(?P<value>[^']*)'"
)
_NEXT_ARRIVING_RE = re.compile(
r"Your deliveries are\s*(?P<day>[A-Za-z]+)\.\s*"
r"Next Arriving:\s*(?P<date>[A-Za-z]+\s+\d+(?:st|nd|rd|th)?,\s*\d{4})",
re.IGNORECASE,
)
_TAB_DATE_RE = re.compile(r"(?P<month>\d{1,2})/(?P<day>\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)
+10 -5
View File
@@ -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:
+1 -1
View File
@@ -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"
}
+63 -19
View File
@@ -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)
+4 -3
View File
@@ -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" }
}
}
}
+92
View File
@@ -0,0 +1,92 @@
<!--
Minimal stand-in for /p/dashboard/details, mirroring the real portal markup.
Synthetic on purpose: the live page carries the account holder's name,
address and phone number, which must not live in the repo.
-->
<html>
<body>
<div class='nav-main-cart'>
<div class='cart-selector-options-wrapper' data-cart-select='2772590'>
<div data-cart-select='2772590'>Tue 8/4</div>
</div>
<div class='cart-selector-options-wrapper' data-cart-select='2778336'>
<div data-cart-select='2778336'>Tue 8/11</div>
</div>
</div>
<!-- Open cart: still customizable, contents not yet assigned. -->
<div class='cart-contents' data-cart-select='2778336'>
<div class='cart-basket-columns'>
<h6>Georgia Grown Small Box</h6>
<div class='item-total'>$33.00</div>
<div class='cart-customize-wrapper'><div>Shop tomorrow</div></div>
</div>
</div>
<div id='OrderTotals-2778336'>
<div class='cart-summary'>
<span class='summary-item label'>Subtotal</span><span class='summary-item value'>$33.00</span>
<span class='summary-item label'>Driver Tip</span><span class='summary-item value'>Add Tip</span>
<span class='summary-item label'>Tax</span><span class='summary-item value'>$0.99</span>
<span class='summary-item label'>Delivery</span><span class='summary-item value'>$5.99</span>
<span class='summary-item label demi-bold total'>Order Total <span class='order-total-see-details'>See Details</span></span><span class='summary-item value'>$39.98</span>
</div>
</div>
<!-- Locked cart: past its cutoff, arriving next, contents final. -->
<div class='cart-contents cart-contents-skipped' data-cart-select='2772590'>
<div class='cart-basket-columns'>
<h6>Georgia Grown Small Box</h6>
<div class='item-total'>$33.00</div>
<div class='cart-customize-wrapper'></div>
</div>
<div class='basket-contents'>
<div id='DODRow-1' class='cart-item basket-item'>
<div class='basket-item-details'>
<span class='basket-item-quantity'>1</span>
<span class='basket-item-name'>Bolero Carrots</span>
<span class='basket-item-uom'>.5 lb</span>
</div>
</div>
<div id='DODRow-2' class='cart-item basket-item'>
<div class='basket-item-details'>
<span class='basket-item-quantity'>2</span>
<span class='basket-item-name'>Georgia Peaches</span>
<span class='basket-item-uom'>6 count</span>
</div>
</div>
</div>
<div id='DODRow-3' class='cart-item qv-button shop-item-cursor' data-qvid='8961'>
<img class='cart-item-img' src='https://cdn.freshharvest.com/x.jpg' alt='Black Mission Figs' />
<div class='item-details'>
<span class='item-name'><div>Black Mission Figs</div></span>
<span class='item-total'>$7.99</span>
<span class='item-uom'><div>1 pint</div><div class='snap-tag-cart'></div></span>
<div class='item-order-wrapper'>
<div class='item-order item-order-disabled'>
<div class='btn-round item-order-remove qv-ignore'></div>
<span class='item-order-quantity qv-ignore' data-item-quantity='8961'>1</span>
<div class='btn-round item-order-add qv-ignore'></div>
</div>
</div>
</div>
</div>
</div>
<div id='OrderTotals-2772590'>
<div class='cart-summary'>
<span class='summary-item label'>Subtotal</span><span class='summary-item value'>$105.88</span>
<span class='summary-item label'>Tax</span><span class='summary-item value'>$3.18</span>
<span class='summary-item label'>Delivery</span><span class='summary-item value'>$0.00</span>
<span class='summary-item label demi-bold total'>Order Total <span>See Details</span></span><span class='summary-item value'>$109.06</span>
</div>
</div>
<div class='account'>
<div class='account-item'>
<div class='account-item-heading'>Delivery Details</div>
<div class='account-item-text'>Your deliveries are Tuesdays. Next Arriving: August 4th, 2026</div>
</div>
</div>
<a href='#'>Sign Out</a>
</body>
</html>
+110
View File
@@ -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("<html><body>signed out</body></html>")