# ha-freshharvest [![Validate](https://github.com/sudolulo/ha-freshharvest/actions/workflows/validate.yml/badge.svg?branch=main)](https://github.com/sudolulo/ha-freshharvest/actions) [![Upstream compatibility](https://github.com/sudolulo/ha-freshharvest/actions/workflows/compat.yml/badge.svg?branch=main)](https://github.com/sudolulo/ha-freshharvest/actions/workflows/compat.yml) [![HACS: custom](https://img.shields.io/badge/HACS-custom-41BDF5?logo=homeassistant&logoColor=white)](https://hacs.xyz/docs/faq/custom_repositories) [![Home Assistant 2025.2+](https://img.shields.io/badge/Home%20Assistant-2025.2%2B-41BDF5?logo=homeassistant&logoColor=white)](https://www.home-assistant.io/) [![License: MIT](https://img.shields.io/badge/License-MIT-blue)](LICENSE) [![GitHub Sponsors](https://img.shields.io/badge/GitHub%20Sponsors-%E2%9D%A4-EA4AAA?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/sudolulo) [![Ko-fi](https://img.shields.io/badge/Ko--fi-support-FF5E5B?logo=kofi&logoColor=white)](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. _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 | 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 `