It still claimed a refresh was a single GET; it has been three since subscriptions and vacation holds were added, and write actions cost more again because every mutating endpoint needs its tokens fetched first. Promotes three more markup traps out of commit messages and into the docs: popups are fragments with no Sign Out control, openPobyup is written with and without a space after the comma, and a select's id is not its POST field. Each cost real debugging time and none is guessable from outside. Notes that every break so far has been silent, which is the argument for the compatibility checks existing at all.
226 lines
10 KiB
Markdown
226 lines
10 KiB
Markdown
# ha-freshharvest
|
|
|
|
[](https://github.com/sudolulo/ha-freshharvest/actions) [](https://github.com/sudolulo/ha-freshharvest/actions/workflows/compat.yml) [](https://hacs.xyz/docs/faq/custom_repositories) [](https://www.home-assistant.io/) [](LICENSE) [](https://github.com/sponsors/sudolulo) [](https://ko-fi.com/sudolulo)
|
|
|
|
Unofficial Home Assistant integration for [Fresh Harvest](https://freshharvest.com/),
|
|
the Georgia local-produce delivery subscription.
|
|
|
|
Reports what is arriving, what is in the box, what it costs — broken down per
|
|
line so you can automate on any single part — and how long you have left to
|
|
change the next order.
|
|
|
|
## Installation
|
|
|
|
### HACS (custom repository)
|
|
|
|
This is not in the HACS default list. Add it yourself:
|
|
|
|
1. HACS → ⋮ → **Custom repositories**
|
|
2. Repository `https://github.com/sudolulo/ha-freshharvest`, category **Integration**
|
|
3. Install **Fresh Harvest**, then restart Home Assistant
|
|
4. **Settings → Devices & Services → Add Integration → Fresh Harvest**
|
|
|
|
### Manual
|
|
|
|
Copy `custom_components/freshharvest/` into your Home Assistant
|
|
`config/custom_components/` directory and restart, then add the integration as
|
|
above.
|
|
|
|
Credentials are your normal freshharvest.com email and password.
|
|
|
|
## Entities
|
|
|
|
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 — so both are exposed separately.
|
|
|
|
Three things arrive in a delivery and only one is a list you edit:
|
|
|
|
| | What it is | Entity |
|
|
| --- | --- | --- |
|
|
| **Produce box** | Chosen, not assembled. Ten options. | `select.fresh_harvest_produce_box` |
|
|
| **Box contents** | Fresh Harvest fills it; read-only. | `produce` attr of `..._next_delivery_items` |
|
|
| **Add-ons** | Yours to add and remove. | `todo.fresh_harvest_add_ons` |
|
|
|
|
### Controls
|
|
|
|
| Entity | Notes |
|
|
| --- | --- |
|
|
| `select.fresh_harvest_produce_box` | Switches the **next delivery only**, not the standing order |
|
|
| `switch.fresh_harvest_skip_next_order` | Skip, and turn back off to restore |
|
|
| `button.fresh_harvest_donate_next_order` | Donates the box. **Not reversible** |
|
|
| `todo.fresh_harvest_add_ons` | Add/remove items; names resolve via the site's search index |
|
|
|
|
Home Assistant's built-in conversation agent can drive the to-do list through
|
|
`HassListAddItem`, so no bespoke voice work is needed.
|
|
|
|
### The order arriving next
|
|
|
|
| Entity | Example |
|
|
| --- | --- |
|
|
| `sensor.fresh_harvest_next_delivery` | `2026-08-04` |
|
|
| `sensor.fresh_harvest_next_delivery_total` | `66.16` |
|
|
| `sensor.fresh_harvest_next_delivery_subtotal` | `58.42` |
|
|
| `sensor.fresh_harvest_next_delivery_box_price` | `33.00` |
|
|
| `sensor.fresh_harvest_next_delivery_add_ons` | `25.42` |
|
|
| `sensor.fresh_harvest_next_delivery_tax` | `1.75` |
|
|
| `sensor.fresh_harvest_next_delivery_fee` | `5.99` |
|
|
| `sensor.fresh_harvest_next_delivery_items` | `11` |
|
|
|
|
### The order you can still change
|
|
|
|
| Entity | Example |
|
|
| --- | --- |
|
|
| `binary_sensor.fresh_harvest_order_open` | `on` |
|
|
| `sensor.fresh_harvest_open_order_delivery` | `2026-08-11` |
|
|
| `sensor.fresh_harvest_open_order_total` | `38.99` |
|
|
| `sensor.fresh_harvest_open_order_free_delivery_remaining` | `11.58` |
|
|
| `sensor.fresh_harvest_shopping_window` | `Shop tomorrow` |
|
|
|
|
`binary_sensor.fresh_harvest_order_open` is the one to automate on: it turns off
|
|
when the cutoff passes, the last moment to change the box.
|
|
|
|
### The account
|
|
|
|
| Entity | Example |
|
|
| --- | --- |
|
|
| `sensor.fresh_harvest_delivery_day` | `Tuesdays` |
|
|
| `sensor.fresh_harvest_subscriptions` | `1`, with each standing order in attributes |
|
|
| `sensor.fresh_harvest_vacation_holds` | `0`, with ranges in attributes |
|
|
|
|
## Events
|
|
|
|
Every write action fires `freshharvest_action` with `action`, `success`,
|
|
`target` and `detail`, so an automation can notify on an add succeeding or a
|
|
skip failing.
|
|
|
|
## Consistency guarantees
|
|
|
|
Two invariants hold against the portal's own arithmetic, and tests assert both:
|
|
|
|
- `next_delivery_box_price` + `next_delivery_add_ons` == `next_delivery_subtotal`
|
|
- `open_order_free_delivery_remaining` reaching `0.00` always coincides with a
|
|
`0.00` delivery fee
|
|
|
|
## Upstream compatibility
|
|
|
|
freshharvest.com has no API and no stability contract — this integration reads
|
|
HTML and posts to form endpoints, so a redesign can change what a value *means*
|
|
without changing its shape. [tools/compat.py](tools/compat.py) records every
|
|
assumption and CI asserts them against the live site daily, refreshing this
|
|
table and opening an issue on drift.
|
|
|
|
<!-- COMPAT:START -->
|
|
_Last checked 2026-08-03._
|
|
|
|
| Area | Assumption | Status | Detail |
|
|
| --- | --- | --- | --- |
|
|
| Login | `/s/popup/login` serves the form | ✅ | 2273 bytes |
|
|
| Login | hidden `LoginSecurity` is minted | ✅ | 154 chars |
|
|
| Login | hidden `SubmitToken` is minted | ✅ | 174 chars |
|
|
| Login | posts to `/s/submit/login` | ✅ | /s/submit/login |
|
|
| Login | field `LoginEmail` present | ✅ | |
|
|
| Login | field `LoginPassword` present | ✅ | |
|
|
| Catalogue | Algolia credentials readable from site JS | ✅ | app id + search key found |
|
|
| Catalogue | index name readable | ✅ | dev_FullTest |
|
|
| Catalogue | index returns a plausible catalogue | ✅ | 946 records |
|
|
| Catalogue | record field `ID` | ✅ | present |
|
|
| Catalogue | record field `Name` | ✅ | present |
|
|
| Catalogue | record field `Price` | ✅ | present |
|
|
| Catalogue | record field `Measurement` | ✅ | present |
|
|
| Catalogue | record field `Categories` | ✅ | present |
|
|
| Endpoints | cart add/remove URL shape unchanged | ✅ | /p/Ajax/order-manage/ |
|
|
| Endpoints | popup route is `/x/popup/{type}/{token}` | ✅ | found |
|
|
<!-- COMPAT:END -->
|
|
|
|
Only the unauthenticated surface is checked here. The authenticated contract —
|
|
dashboard markup, cart add hashes, skip popups, subscribe forms — needs a real
|
|
session, and the only way to give public CI one is to put a personal grocery
|
|
account's password in repo secrets. That half runs on a host that already holds
|
|
the credential.
|
|
|
|
Worth knowing if you fork this: every markup break so far has been **silent**.
|
|
Subscription rows moved and the integration reported `0` subscriptions; hold
|
|
dates were not ISO and it reported `0` holds. A sensor reading zero is
|
|
indistinguishable from an account with nothing in it, which is exactly why
|
|
these are asserted rather than left to be noticed.
|
|
|
|
## 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 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`, yielding an `fh_session_authenticated` cookie.
|
|
|
|
The tokens are bound to the cookie issued by step 1, so both requests must
|
|
share a cookie jar.
|
|
|
|
A refresh is three GETs: `/p/dashboard/details` for the delivery day, next
|
|
arrival, both upcoming carts and their totals; `/p/dashboard/manage-subscriptions`
|
|
for standing orders; and `/p/dashboard/pause-deliveries` for vacation holds.
|
|
Three requests every six hours.
|
|
|
|
Write actions cost more, because nothing can be constructed offline — every
|
|
mutating endpoint is guarded by rotating per-render tokens, so each action
|
|
fetches the page that offers it, reads fresh tokens, checks they describe the
|
|
intended target, and only then submits.
|
|
|
|
## Markup notes
|
|
|
|
Six traps, none guessable from the outside:
|
|
|
|
- **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`.
|
|
- **The free-delivery bar only renders on carts below the threshold.** An order
|
|
that already qualifies has no bar at all, so the threshold is read once from
|
|
whichever cart shows it and applied to every order.
|
|
- **Popups and AJAX replies are fragments, not pages.** They carry no
|
|
navigation, so a "am I still signed in?" check based on a Sign Out control
|
|
reads every one of them as logged out. Skip could not run at all until these
|
|
were fetched with that check disabled.
|
|
- **`openPopup` is written both `("x","y")` and `("x", "y")`.** A regex
|
|
requiring no space silently matches nothing on the pages that use the other
|
|
form — which is every basket page.
|
|
- **A `<select>`'s `id` is not its POST field.** The subscribe form's frequency
|
|
control is `id='FrequencyID'` but `name='popup-toggle'`. Posting
|
|
`FrequencyID` is accepted and does nothing.
|
|
|
|
## Dashboard
|
|
|
|
[examples/dashboard-view.yaml](examples/dashboard-view.yaml) is a ready-made tab
|
|
— a countdown heading ("Arriving tomorrow"), tiles for the cost breakdown, the
|
|
full box contents rendered from the attributes, and the still-changeable order.
|
|
Paste it under `views:` in the raw configuration editor.
|
|
|
|
## Tests
|
|
|
|
```
|
|
pip install beautifulsoup4 pytest yarl
|
|
pytest tests/
|
|
```
|
|
|
|
The fixture is synthetic but mirrors the real markup, with placeholder cart IDs
|
|
and self-consistent totals; the live page carries the account holder's name,
|
|
address and phone number, so it is never committed.
|
|
|
|
## Compatibility
|
|
|
|
Requires Home Assistant 2025.2 or newer. Developed and running against 2026.7.
|
|
|
|
## Disclaimer
|
|
|
|
Unofficial and unaffiliated — not endorsed by or supported by Fresh Harvest.
|
|
Please do not lower the six-hour poll interval: this is a small business's
|
|
website, not an API.
|