Expose every order component as its own entity and prepare for release
Validate / hassfest (push) Failing after 23s
Validate / pytest (push) Successful in 9s
Validate / HACS (push) Failing after 1m21s

Each cost line is now an entity rather than an attribute: subtotal, box price,
add-ons, tax and delivery fee, plus a total and free-delivery-remaining for the
open order, a delivery-day sensor, and a binary sensor that turns off at the
cutoff. Entities share a base class and declare a scope, so a description
states only the field it reads.

Also parses the driver tip, Bounty savings and the free-delivery threshold. The
threshold is carried across orders because the progress bar only renders on
carts that have not met it.

Adds LICENSE, hacs.json, a CI workflow, a pre-publish audit script, and a test
that cross-checks every entity's translation_key against both translation
files. Manifest URLs now point at GitHub rather than a private forge.
This commit is contained in:
flan
2026-08-03 19:32:50 +00:00
parent 527f662189
commit 3e880ff2c1
18 changed files with 760 additions and 144 deletions
+91 -33
View File
@@ -3,25 +3,81 @@
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
| 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_add_ons` | `72.88` | Add-ons only, excluding the box. Attributes: `box_price`, `add_ons` |
| `sensor.fresh_harvest_next_delivery_items` | `14` | Attributes: `box`, `box_price`, `produce`, `add_ons`, `add_ons_total` |
| `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 |
Each `add_ons` entry carries its quantity, unit, and extended price — a
multi-quantity line bills as one amount, so 4 smoothies read
`4 Complete Recovery Smoothie 15.2 fl oz — $17.96`. `add_ons_total` plus
`box_price` always equals the portal's own subtotal, which a test asserts.
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.
edit is the following week's — so both are exposed separately.
### 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` |
`next_delivery_items` carries the contents as attributes: `produce`, `add_ons`
(each with quantity, unit and extended price), `produce_count`, `add_ons_count`
and `box`. The totals sensor carries `driver_tip` and `bounty_savings`, which
are optional or promotional rather than charges.
### 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, which is the last moment to add anything to the box.
`shopping_window` reads `closed` when nothing is changeable — distinct from
unknown.
### The account
| Entity | Example |
| --- | --- |
| `sensor.fresh_harvest_delivery_day` | `Tuesdays` |
## 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
## How it works
@@ -37,13 +93,14 @@ Login is a two-step handshake:
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. Everything the integration needs then comes from a single
share a cookie jar. Everything 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.
both upcoming carts, their contents, and their totals. One request per refresh,
every six hours.
## Markup notes
Two traps are worth recording, since neither is guessable from the outside:
Three 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
@@ -52,21 +109,16 @@ Two traps are worth recording, since neither is guessable from the outside:
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.
## Dashboard
[examples/dashboard-view.yaml](examples/dashboard-view.yaml) is a ready-made tab
for these sensors — a countdown heading ("Arriving tomorrow"), tiles for the
date, total and item count, the full box contents rendered from the attributes,
and the still-changeable order with a link back to the portal. Paste it under
`views:` in the raw configuration editor.
## Installation
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.
— 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
@@ -75,10 +127,16 @@ 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.
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. Polls once every 6 hours; please do not lower that
— this is a small business's website, not an API.
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.