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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user