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
+41
View File
@@ -0,0 +1,41 @@
name: Validate
on:
push:
pull_request:
schedule:
# Catches HACS/hassfest rule changes without a push.
- cron: "0 6 * * 1"
workflow_dispatch:
jobs:
hassfest:
name: hassfest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: home-assistant/actions/hassfest@master
hacs:
name: HACS
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hacs/action@main
with:
category: integration
# This is distributed as a custom repository, not a default one, so
# the brands check (which requires a home-assistant/brands PR) is
# deliberately skipped.
ignore: brands
tests:
name: pytest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install beautifulsoup4 pytest
- run: pytest tests/ -q
+30
View File
@@ -5,6 +5,36 @@ 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).
## [0.3.0] - 2026-08-03
Prepared for public release as a HACS custom repository.
### Added
- Every cost component is now its own entity rather than an attribute:
subtotal, box price, add-ons, tax, and delivery fee for the next delivery,
plus a total and free-delivery-remaining for the open order.
- `binary_sensor.fresh_harvest_order_open`, which turns off when the cutoff
passes — the last moment to change the box.
- `sensor.fresh_harvest_delivery_day`.
- Parsing for the driver tip, potential Bounty savings, and the free-delivery
threshold, with the threshold carried across orders because the progress bar
only renders on carts below it.
- `LICENSE` (MIT), `hacs.json`, and a CI workflow running hassfest, the HACS
action, and the test suite.
- `tests/test_translations.py`, which cross-checks every entity's
`translation_key` against both translation files and fails on an orphan or a
missing name.
### Changed
- Entities share a `FreshHarvestEntity` base and declare a `scope`
(`account`, `next_order`, `open_order`), so a description states only the
field it reads instead of repeating None handling.
- `manifest.json` documentation and issue-tracker URLs now point at GitHub;
they previously pointed at a private forge that no installer could reach.
- Fixture cart identifiers replaced with placeholders.
## [0.2.0] - 2026-08-03
### Added
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 flan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+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.
+1 -1
View File
@@ -10,7 +10,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .api import FreshHarvestClient
from .coordinator import FreshHarvestCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
type FreshHarvestConfigEntry = ConfigEntry[FreshHarvestCoordinator]
+29
View File
@@ -96,7 +96,12 @@ class DeliveryOrder:
subtotal: float | None = None
tax: float | None = None
delivery_fee: float | None = None
driver_tip: float | None = None
total: float | None = None
# What a Bounty membership would knock off this order. Marketing, not a charge.
bounty_savings: float | None = None
# How much more this order needs to qualify for free delivery, 0.0 once it does.
free_delivery_remaining: float | 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
@@ -126,6 +131,9 @@ class AccountSnapshot:
next_delivery: date | None = None
delivery_day: str | None = None
# Account-wide spend needed for free delivery. Only rendered on carts that
# have not reached it, so it is read once and applied to every order.
free_delivery_threshold: float | None = None
orders: list[DeliveryOrder] = field(default_factory=list)
@property
@@ -262,9 +270,30 @@ def parse_dashboard(html: str) -> AccountSnapshot:
order.tax = amount
elif key.startswith("delivery"):
order.delivery_fee = amount
elif key.startswith("driver tip"):
# Reads "Add Tip" until one is set, which is not an amount.
order.driver_tip = amount
elif "bounty savings" in key:
order.bounty_savings = amount
progress = soup.select_one(f"#DeliveryProgressBar-{delivery_id} progress")
if progress is not None and snapshot.free_delivery_threshold is None:
try:
snapshot.free_delivery_threshold = float(progress.get("max"))
except (TypeError, ValueError):
_LOGGER.debug("unparsable free-delivery threshold on %s", delivery_id)
snapshot.orders.append(order)
# Applied after the loop: the threshold is only rendered on carts that have
# not met it, so an order that already qualifies would otherwise miss it.
if snapshot.free_delivery_threshold is not None:
for order in snapshot.orders:
if order.subtotal is not None:
order.free_delivery_remaining = round(
max(0.0, snapshot.free_delivery_threshold - order.subtotal), 2
)
if not snapshot.orders and snapshot.next_delivery is None:
raise FreshHarvestError("dashboard markup not recognised")
return snapshot
@@ -0,0 +1,62 @@
"""Binary sensors for Fresh Harvest."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import FreshHarvestConfigEntry
from .coordinator import FreshHarvestCoordinator
from .entity import FreshHarvestEntity
DESCRIPTION = BinarySensorEntityDescription(
key="order_open",
translation_key="order_open",
icon="mdi:cart-arrow-right",
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FreshHarvestConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the binary sensor platform."""
async_add_entities([FreshHarvestOrderOpen(entry.runtime_data, entry)])
class FreshHarvestOrderOpen(FreshHarvestEntity, BinarySensorEntity):
"""Whether any upcoming order can still be changed.
The single most useful thing to automate on: it goes off when the cutoff
passes, which is the last moment to add something to the box.
"""
entity_description = DESCRIPTION
def __init__(
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
) -> None:
super().__init__(coordinator, entry, DESCRIPTION.key)
@property
def is_on(self) -> bool:
"""Return true while an order is still customizable."""
return self.target("open_order") is not None
@property
def extra_state_attributes(self) -> dict[str, str | None] | None:
"""Surface which order is open and until when."""
order = self.target("open_order")
if order is None:
return None
return {
"delivery_date": order.delivery_date.isoformat()
if order.delivery_date
else None,
"shop_window": order.shop_window,
}
+56
View File
@@ -0,0 +1,56 @@
"""Shared entity plumbing for Fresh Harvest.
Entities read one of three things: the account as a whole, the order arriving
next, or the order that can still be changed. Declaring that as a `scope` keeps
each entity's `value_fn` down to the field it actually cares about, instead of
every one of them repeating the same None checks.
"""
from __future__ import annotations
from typing import Literal
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .api import AccountSnapshot, DeliveryOrder
from .const import DOMAIN
from .coordinator import FreshHarvestCoordinator
Scope = Literal["account", "next_order", "open_order"]
def resolve_scope(
snapshot: AccountSnapshot, scope: Scope
) -> AccountSnapshot | DeliveryOrder | None:
"""Return the object a scope points at, or None when there is no such order."""
if scope == "account":
return snapshot
return getattr(snapshot, scope)
class FreshHarvestEntity(CoordinatorEntity[FreshHarvestCoordinator]):
"""Base for every Fresh Harvest entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: FreshHarvestCoordinator,
entry: ConfigEntry,
key: str,
) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{entry.entry_id}_{key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Fresh Harvest",
manufacturer="Fresh Harvest",
entry_type=DeviceEntryType.SERVICE,
configuration_url="https://freshharvest.com/p/dashboard/details",
)
def target(self, scope: Scope) -> AccountSnapshot | DeliveryOrder | None:
"""Resolve this entity's scope against the latest snapshot."""
return resolve_scope(self.coordinator.data, scope)
+5 -9
View File
@@ -1,16 +1,12 @@
{
"domain": "freshharvest",
"name": "Fresh Harvest",
"codeowners": [
"@flan"
],
"codeowners": ["@sudolulo"],
"config_flow": true,
"documentation": "https://git.onetick.ninja/flan/ha-freshharvest",
"documentation": "https://github.com/sudolulo/ha-freshharvest",
"integration_type": "service",
"iot_class": "cloud_polling",
"issue_tracker": "https://git.onetick.ninja/flan/ha-freshharvest/issues",
"requirements": [
"beautifulsoup4>=4.12"
],
"version": "0.2.0"
"issue_tracker": "https://github.com/sudolulo/ha-freshharvest/issues",
"requirements": ["beautifulsoup4>=4.12"],
"version": "0.3.0"
}
+114 -67
View File
@@ -1,4 +1,9 @@
"""Sensors for Fresh Harvest."""
"""Sensors for Fresh Harvest.
Every part of an order is its own entity — box price, add-ons, tax, delivery
fee, subtotal and total — so an automation or dashboard can read any one of
them directly rather than digging through attributes.
"""
from __future__ import annotations
@@ -13,17 +18,17 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FreshHarvestConfigEntry
from .api import AccountSnapshot, DeliveryOrder, OrderItem
from .const import DOMAIN
from .api import DeliveryOrder, OrderItem
from .coordinator import FreshHarvestCoordinator
from .entity import FreshHarvestEntity, Scope
CURRENCY = "USD"
def _format_item(item: OrderItem, with_price: bool = False) -> str:
def format_item(item: OrderItem, with_price: bool = False) -> str:
"""Render one line, e.g. '4 Complete Recovery Smoothie 15.2 fl oz — $17.96'."""
line = " ".join(
part for part in (str(item.quantity or ""), item.name, item.unit) if part
@@ -33,15 +38,21 @@ def _format_item(item: OrderItem, with_price: bool = False) -> str:
return line
def _items_attrs(order: DeliveryOrder | None) -> dict[str, Any] | None:
if order is None:
return None
def _contents_attrs(order: DeliveryOrder) -> dict[str, Any]:
return {
"box": order.box_name,
"box_price": order.box_price,
"produce": [_format_item(i) for i in order.items],
"add_ons": [_format_item(a, with_price=True) for a in order.addons],
"add_ons_total": order.addons_total,
"produce": [format_item(i) for i in order.items],
"add_ons": [format_item(a, with_price=True) for a in order.addons],
"produce_count": len(order.items),
"add_ons_count": len(order.addons),
}
def _total_attrs(order: DeliveryOrder) -> dict[str, Any]:
"""Charges that are not worth their own entity: optional or promotional."""
return {
"driver_tip": order.driver_tip,
"bounty_savings": order.bounty_savings,
}
@@ -49,77 +60,116 @@ def _items_attrs(order: DeliveryOrder | None) -> dict[str, Any] | None:
class FreshHarvestSensorDescription(SensorEntityDescription):
"""Describes a Fresh Harvest sensor."""
value_fn: Callable[[AccountSnapshot], Any]
attrs_fn: Callable[[AccountSnapshot], dict[str, Any] | None] | None = None
scope: Scope = "next_order"
value_fn: Callable[[Any], Any]
attrs_fn: Callable[[Any], dict[str, Any]] | None = None
def _money(**kwargs) -> FreshHarvestSensorDescription:
"""A dollar amount. A helper because there are eight of them.
Callers pass `translation_key` explicitly rather than deriving it from
`key`, so the name of every entity stays greppable in this file — which is
what `tests/test_translations.py` checks.
"""
return FreshHarvestSensorDescription(
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=CURRENCY,
**kwargs,
)
SENSORS: tuple[FreshHarvestSensorDescription, ...] = (
# --- the account itself -------------------------------------------------
FreshHarvestSensorDescription(
key="next_delivery",
translation_key="next_delivery",
scope="account",
device_class=SensorDeviceClass.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,
"free_delivery_threshold": s.free_delivery_threshold,
},
),
FreshHarvestSensorDescription(
key="delivery_day",
translation_key="delivery_day",
scope="account",
icon="mdi:calendar-week",
value_fn=lambda s: s.delivery_day,
),
# --- the order arriving next --------------------------------------------
_money(
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 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,
},
value_fn=lambda o: o.total,
attrs_fn=_total_attrs,
),
FreshHarvestSensorDescription(
key="next_delivery_addons_total",
translation_key="next_delivery_addons_total",
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="USD",
value_fn=lambda s: s.next_order.addons_total if s.next_order else None,
attrs_fn=lambda s: None
if s.next_order is None
else {
"box_price": s.next_order.box_price,
"add_ons": [
_format_item(a, with_price=True) for a in s.next_order.addons
],
},
_money(
key="next_delivery_subtotal",
translation_key="next_delivery_subtotal",
value_fn=lambda o: o.subtotal,
),
_money(
key="next_delivery_box_price",
translation_key="next_delivery_box_price",
value_fn=lambda o: o.box_price,
),
_money(
key="next_delivery_add_ons",
translation_key="next_delivery_add_ons",
value_fn=lambda o: o.addons_total,
),
_money(
key="next_delivery_tax",
translation_key="next_delivery_tax",
value_fn=lambda o: o.tax,
),
_money(
key="next_delivery_delivery_fee",
translation_key="next_delivery_delivery_fee",
value_fn=lambda o: o.delivery_fee,
),
FreshHarvestSensorDescription(
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),
icon="mdi:basket-check",
value_fn=lambda o: len(o.all_items),
attrs_fn=_contents_attrs,
),
# --- the order that can still be changed --------------------------------
FreshHarvestSensorDescription(
key="open_order_delivery",
translation_key="open_order_delivery",
scope="open_order",
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,
},
value_fn=lambda o: o.delivery_date,
attrs_fn=lambda o: {"box": o.box_name},
),
_money(
key="open_order_total",
translation_key="open_order_total",
scope="open_order",
value_fn=lambda o: o.total,
attrs_fn=_total_attrs,
),
_money(
key="open_order_free_delivery_remaining",
translation_key="open_order_free_delivery_remaining",
scope="open_order",
value_fn=lambda o: o.free_delivery_remaining,
),
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",
scope="open_order",
icon="mdi:clock-alert-outline",
# Distinct from unknown: there genuinely is no changeable order.
value_fn=lambda o: o.shop_window or "closed",
),
)
@@ -136,10 +186,9 @@ async def async_setup_entry(
)
class FreshHarvestSensor(CoordinatorEntity[FreshHarvestCoordinator], SensorEntity):
class FreshHarvestSensor(FreshHarvestEntity, SensorEntity):
"""A value read off the Fresh Harvest dashboard."""
_attr_has_entity_name = True
entity_description: FreshHarvestSensorDescription
def __init__(
@@ -148,25 +197,23 @@ class FreshHarvestSensor(CoordinatorEntity[FreshHarvestCoordinator], SensorEntit
entry: FreshHarvestConfigEntry,
description: FreshHarvestSensorDescription,
) -> None:
super().__init__(coordinator)
super().__init__(coordinator, entry, description.key)
self.entity_description = description
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Fresh Harvest",
manufacturer="Fresh Harvest",
entry_type=DeviceEntryType.SERVICE,
configuration_url="https://freshharvest.com/p/dashboard/details",
)
@property
def native_value(self) -> Any:
"""Return the sensor value."""
return self.entity_description.value_fn(self.coordinator.data)
"""Return the sensor value, or None when its order does not exist."""
target = self.target(self.entity_description.scope)
if target is None:
return None
return self.entity_description.value_fn(target)
@property
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 self.entity_description.attrs_fn(self.coordinator.data)
target = self.target(self.entity_description.scope)
if target is None:
return None
return self.entity_description.attrs_fn(target)
+27 -1
View File
@@ -22,21 +22,47 @@
"next_delivery": {
"name": "Next delivery"
},
"delivery_day": {
"name": "Delivery day"
},
"next_delivery_total": {
"name": "Next delivery total"
},
"next_delivery_addons_total": {
"next_delivery_subtotal": {
"name": "Next delivery subtotal"
},
"next_delivery_box_price": {
"name": "Next delivery box price"
},
"next_delivery_add_ons": {
"name": "Next delivery add-ons"
},
"next_delivery_tax": {
"name": "Next delivery tax"
},
"next_delivery_delivery_fee": {
"name": "Next delivery fee"
},
"next_delivery_items": {
"name": "Next delivery items"
},
"open_order_delivery": {
"name": "Open order delivery"
},
"open_order_total": {
"name": "Open order total"
},
"open_order_free_delivery_remaining": {
"name": "Open order free delivery remaining"
},
"shop_window": {
"name": "Shopping window"
}
},
"binary_sensor": {
"order_open": {
"name": "Order open"
}
}
}
}
@@ -22,21 +22,47 @@
"next_delivery": {
"name": "Next delivery"
},
"delivery_day": {
"name": "Delivery day"
},
"next_delivery_total": {
"name": "Next delivery total"
},
"next_delivery_addons_total": {
"next_delivery_subtotal": {
"name": "Next delivery subtotal"
},
"next_delivery_box_price": {
"name": "Next delivery box price"
},
"next_delivery_add_ons": {
"name": "Next delivery add-ons"
},
"next_delivery_tax": {
"name": "Next delivery tax"
},
"next_delivery_delivery_fee": {
"name": "Next delivery fee"
},
"next_delivery_items": {
"name": "Next delivery items"
},
"open_order_delivery": {
"name": "Open order delivery"
},
"open_order_total": {
"name": "Open order total"
},
"open_order_free_delivery_remaining": {
"name": "Open order free delivery remaining"
},
"shop_window": {
"name": "Shopping window"
}
},
"binary_sensor": {
"order_open": {
"name": "Order open"
}
}
}
}
+67 -14
View File
@@ -1,11 +1,11 @@
# Fresh Harvest dashboard view.
#
# This is the tab shipped alongside the integration: three sections covering the
# next delivery, what is in the box, and the order that can still be changed.
# Four sections: the next delivery, its cost broken down per line, what is in
# the box, and the order that can still be changed.
#
# To use it, open your dashboard, choose "Edit dashboard" -> "Raw configuration
# editor", and paste this under `views:`. It only needs the six sensors the
# integration creates.
# editor", and paste this under `views:`. It needs the entities this
# integration creates and nothing else — no custom cards.
type: sections
max_columns: 4
@@ -23,8 +23,7 @@ sections:
{%- set d = states('sensor.fresh_harvest_next_delivery') -%}
{%- set box = state_attr('sensor.fresh_harvest_next_delivery_items', 'box') -%}
{%- if d not in ['unknown', 'unavailable', 'none'] -%}
{%- set days = (d | as_datetime).date() - now().date() -%}
{%- set n = days.days -%}
{%- set n = ((d | as_datetime).date() - now().date()).days -%}
## {% if n < 0 %}Delivered{% elif n == 0 %}Arriving today{% elif n == 1 %}Arriving tomorrow{% else %}Arriving in {{ n }} days{% endif %}
{{ (d | as_datetime).strftime('%A, %B %-d') }}{% if box %} · {{ box }}{% endif %}
{%- else -%}
@@ -40,16 +39,50 @@ sections:
name: Order total
icon: mdi:cash-multiple
color: teal
- type: tile
entity: sensor.fresh_harvest_next_delivery_items
name: Items
icon: mdi:basket-check
color: light-green
- type: grid
cards:
- type: heading
heading: Cost Breakdown
heading_style: title
icon: mdi:receipt-text-outline
- type: tile
entity: sensor.fresh_harvest_next_delivery_box_price
name: Produce box
icon: mdi:package-variant
color: green
- type: tile
entity: sensor.fresh_harvest_next_delivery_add_ons
name: Add-ons
icon: mdi:cart-plus
color: purple
- type: tile
entity: sensor.fresh_harvest_next_delivery_items
name: Items
icon: mdi:basket-check
color: light-green
entity: sensor.fresh_harvest_next_delivery_subtotal
name: Subtotal
icon: mdi:calculator
color: grey
- type: tile
entity: sensor.fresh_harvest_next_delivery_tax
name: Tax
icon: mdi:bank
color: grey
- type: tile
entity: sensor.fresh_harvest_next_delivery_fee
name: Delivery fee
icon: mdi:truck-outline
color: grey
- type: markdown
content: |-
{%- set fee = states('sensor.fresh_harvest_next_delivery_fee') | float(-1) -%}
{%- if fee == 0 -%}
**Free delivery** on the order arriving next.
{%- elif fee > 0 -%}
Delivery fee of **${{ '%.2f' | format(fee) }}** applies to this order.
{%- endif -%}
- type: grid
cards:
- type: heading
@@ -60,10 +93,10 @@ sections:
content: |-
{%- set produce = state_attr('sensor.fresh_harvest_next_delivery_items', 'produce') or [] -%}
{%- set addons = state_attr('sensor.fresh_harvest_next_delivery_items', 'add_ons') or [] -%}
{%- set addons_total = state_attr('sensor.fresh_harvest_next_delivery_items', 'add_ons_total') -%}
{%- set box_price = state_attr('sensor.fresh_harvest_next_delivery_items', 'box_price') -%}
{%- set box_price = states('sensor.fresh_harvest_next_delivery_box_price') | float(0) -%}
{%- set addons_total = states('sensor.fresh_harvest_next_delivery_add_ons') | float(0) -%}
{%- if produce -%}
**In the box**{% if box_price %} · ${{ '%.2f' | format(box_price) }}{% endif %}
**In the box** · ${{ '%.2f' | format(box_price) }}
{% for i in produce %}
- {{ i }}
{%- endfor %}
@@ -71,7 +104,7 @@ sections:
_Box contents not assigned yet._
{%- endif %}
{% if addons %}
**Add-ons**{% if addons_total %} · ${{ '%.2f' | format(addons_total) }}{% endif %}
**Add-ons** · ${{ '%.2f' | format(addons_total) }}
{% for a in addons %}
- {{ a }}
{%- endfor %}
@@ -82,6 +115,11 @@ sections:
heading: Still Open
heading_style: title
icon: mdi:cart-arrow-right
- type: tile
entity: binary_sensor.fresh_harvest_order_open
name: Can still change
icon: mdi:pencil-outline
color: amber
- type: tile
entity: sensor.fresh_harvest_open_order_delivery
name: Delivery
@@ -92,6 +130,21 @@ sections:
name: Shopping window
icon: mdi:clock-alert-outline
color: orange
- type: tile
entity: sensor.fresh_harvest_open_order_total
name: Running total
icon: mdi:cash
color: teal
- type: markdown
content: |-
{%- set left = states('sensor.fresh_harvest_open_order_free_delivery_remaining') | float(-1) -%}
{%- set d = states('sensor.fresh_harvest_open_order_delivery') -%}
{%- set when = (d | as_datetime).strftime('%b %-d') if d not in ['unknown', 'unavailable', 'none'] else 'this' -%}
{%- if left == 0 -%}
The **{{ when }}** order already qualifies for free delivery.
{%- elif left > 0 -%}
The **{{ when }}** order needs **${{ '%.2f' | format(left) }} more** for free delivery.
{%- endif -%}
- type: markdown
content: |-
Once an order passes its cutoff it locks for packing, and the following week's cart opens.
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Fresh Harvest",
"content_in_root": false,
"render_readme": true,
"homeassistant": "2025.2.0"
}
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Pre-publish audit: nothing personal, nothing pointing at private infrastructure,
# and every file a public HACS repository needs.
#
# grep exits 1 when it finds nothing, so this judges on OUTPUT, not exit status —
# testing the exit code reports every clean check as a finding.
set -uo pipefail
cd "$(dirname "$0")/.."
findings=0
# This script necessarily contains the very strings it searches for, so it
# excludes itself — otherwise every check reports itself as a finding.
EXCLUDES=(--exclude-dir=.git --exclude-dir=.pytest_cache --exclude-dir=__pycache__
--exclude-dir=.venv --exclude=audit.sh)
check() {
local label="$1"; shift
local out
out="$("$@" 2>/dev/null)"
if [ -z "$out" ]; then
printf ' \033[32m✓\033[0m %s\n' "$label"
else
printf ' \033[31m✗\033[0m %s\n' "$label"
printf '%s\n' "$out" | head -8 | sed 's/^/ /'
findings=$((findings + 1))
fi
}
echo "Content"
check "no account-holder details" \
grep -rniE "holden|salomon|arch\.fyi|garden ln|decatur|\(678|30030" "${EXCLUDES[@]}" .
check "no private infrastructure URLs" \
grep -rniE "onetick|truenas|192\.168\.|ha-box" "${EXCLUDES[@]}" .
check "no real cart identifiers" \
grep -rnE "2772590|2778336" "${EXCLUDES[@]}" .
check "no live account figures in docs" \
grep -rnE "109\.06|105\.88|72\.88" README.md CHANGELOG.md
check "no hardcoded secrets" \
grep -rniE "api[_-]?key\s*[:=]\s*[\"'][^\"']{8,}|access_token\s*[:=]\s*[\"'][^\"']{8,}" \
"${EXCLUDES[@]}" --include=*.py --include=*.json --include=*.yaml .
echo "Required files"
for f in LICENSE hacs.json README.md CHANGELOG.md NOTICE \
.github/workflows/validate.yml \
custom_components/freshharvest/manifest.json \
custom_components/freshharvest/translations/en.json; do
if [ -e "$f" ]; then
printf ' \033[32m✓\033[0m %s\n' "$f"
else
printf ' \033[31m✗\033[0m missing %s\n' "$f"
findings=$((findings + 1))
fi
done
echo "Manifest"
check "manifest URLs are public" \
grep -nE '"(documentation|issue_tracker)": "(?!https://github\.com/)' -P \
custom_components/freshharvest/manifest.json
echo
if [ "$findings" -eq 0 ]; then
printf '\033[32mAudit clean — 0 findings\033[0m\n'
else
printf '\033[31m%s finding(s)\033[0m\n' "$findings"
fi
exit "$findings"
+26 -15
View File
@@ -6,34 +6,43 @@
<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 class='cart-selector-options-wrapper' data-cart-select='1000001'>
<div data-cart-select='1000001'>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 class='cart-selector-options-wrapper' data-cart-select='1000002'>
<div data-cart-select='1000002'>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-contents' data-cart-select='1000002'>
<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'>
<!-- The free-delivery bar is only rendered on carts BELOW the threshold, which
is why the threshold is read once and applied to every order. -->
<div id='DeliveryProgressBar-1000002' class='basket-progress'>
<div class='progress-message'>You're so close! $37.00 away from free delivery!</div>
<div class='progress'>
<progress id='DeliveryProgress-1000002' class='progress-free-delivery' value='33' max='70.00'></progress>
</div>
</div>
<div id='OrderTotals-1000002'>
<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>
<span class='summary-item label'>Potential Bounty Savings</span><span class='summary-item value'>$5.99</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-contents cart-contents-skipped' data-cart-select='1000001'>
<div class='cart-basket-columns'>
<h6>Georgia Grown Small Box</h6>
<div class='item-total'>$33.00</div>
@@ -55,7 +64,7 @@
</div>
</div>
</div>
<div id='DODRow-3' class='cart-item qv-button shop-item-cursor' data-qvid='8961'>
<div id='DODRow-3' class='cart-item qv-button shop-item-cursor' data-qvid='5001'>
<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>
@@ -64,14 +73,14 @@
<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>
<span class='item-order-quantity qv-ignore' data-item-quantity='5001'>1</span>
<div class='btn-round item-order-add qv-ignore'></div>
</div>
</div>
</div>
</div>
<!-- Multi-quantity add-on: item-total is the EXTENDED price, 4 x $4.49. -->
<div id='DODRow-4' class='cart-item qv-button shop-item-cursor' data-qvid='9012'>
<div id='DODRow-4' class='cart-item qv-button shop-item-cursor' data-qvid='5002'>
<img class='cart-item-img' src='https://cdn.freshharvest.com/y.jpg' alt='Complete Recovery Smoothie' />
<div class='item-details'>
<span class='item-name'><div>Complete Recovery Smoothie</div></span>
@@ -80,13 +89,13 @@
<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='9012'>4</span>
<span class='item-order-quantity qv-ignore' data-item-quantity='5002'>4</span>
<div class='btn-round item-order-add qv-ignore'></div>
</div>
</div>
</div>
</div>
<div id='DODRow-5' class='cart-item qv-button shop-item-cursor' data-qvid='9105'>
<div id='DODRow-5' class='cart-item qv-button shop-item-cursor' data-qvid='5003'>
<img class='cart-item-img' src='https://cdn.freshharvest.com/z.jpg' alt='Organic Fruit Punch Juice Boxes' />
<div class='item-details'>
<span class='item-name'><div>Organic Fruit Punch Juice Boxes</div></span>
@@ -95,21 +104,23 @@
<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='9105'>2</span>
<span class='item-order-quantity qv-ignore' data-item-quantity='5003'>2</span>
<div class='btn-round item-order-add qv-ignore'></div>
</div>
</div>
</div>
</div>
</div>
<div id='OrderTotals-2772590'>
<div id='OrderTotals-1000001'>
<div class='cart-summary'>
<!-- Self-consistent: box 33.00 + add-ons 39.93 = 72.93 subtotal, which is
over the $70 free-delivery threshold, hence the $0.00 fee. -->
<span class='summary-item label'>Subtotal</span><span class='summary-item value'>$72.93</span>
<span class='summary-item label'>Driver Tip</span><span class='summary-item value'>$4.00</span>
<span class='summary-item label'>Tax</span><span class='summary-item value'>$2.19</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'>$75.12</span>
<span class='summary-item label demi-bold total'>Order Total <span>See Details</span></span><span class='summary-item value'>$79.12</span>
<span class='summary-item label'>Potential Bounty Savings</span><span class='summary-item value'>$5.69</span>
</div>
</div>
+25 -3
View File
@@ -62,7 +62,7 @@ def test_next_order_is_the_locked_one(snapshot):
class `cart-contents-skipped`, which does not mean the user skipped it.
"""
order = snapshot.next_order
assert order.delivery_id == "2772590"
assert order.delivery_id == "1000001"
assert order.delivery_date == date(2026, 8, 4)
assert order.is_open is False
@@ -71,7 +71,9 @@ 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) == (72.93, 2.19, 0.0)
assert order.total == 75.12
assert order.driver_tip == 4.00
assert order.bounty_savings == 5.69
assert order.total == 79.12
assert [(i.quantity, i.name, i.unit) for i in order.items] == [
(1, "Bolero Carrots", ".5 lb"),
(2, "Georgia Peaches", "6 count"),
@@ -98,13 +100,33 @@ def test_addons_total_is_zero_when_nothing_is_added(snapshot):
def test_open_order(snapshot):
order = snapshot.open_order
assert order.delivery_id == "2778336"
assert order.delivery_id == "1000002"
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_free_delivery_threshold_applies_to_every_order(snapshot):
"""The bar renders only on carts below the threshold, so it must carry over."""
assert snapshot.free_delivery_threshold == 70.0
# This cart has the bar: $70.00 - $33.00 matches the portal's own message.
assert snapshot.open_order.free_delivery_remaining == 37.0
# This one does not, and must still resolve rather than staying unknown.
assert snapshot.next_order.free_delivery_remaining == 0.0
def test_free_delivery_remaining_agrees_with_the_fee_charged(snapshot):
"""Nothing left to spend must mean nothing was charged for delivery."""
for order in snapshot.orders:
assert (order.free_delivery_remaining == 0.0) == (order.delivery_fee == 0.0)
def test_unset_driver_tip_is_none_not_zero(snapshot):
"""An untipped order reads 'Add Tip'; that is unknown, not a $0.00 tip."""
assert snapshot.open_order.driver_tip is None
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
+66
View File
@@ -0,0 +1,66 @@
"""Every entity's translation_key must exist, and nothing may be orphaned.
These parse the platform sources with `ast` rather than importing them, so the
suite still runs without Home Assistant installed.
"""
from __future__ import annotations
import ast
import json
from pathlib import Path
import pytest
COMPONENT = Path(__file__).parent.parent / "custom_components" / "freshharvest"
PLATFORMS = {"sensor": "sensor.py", "binary_sensor": "binary_sensor.py"}
def declared_keys(filename: str) -> set[str]:
"""Collect every `translation_key="..."` literal in a module."""
tree = ast.parse((COMPONENT / filename).read_text(encoding="utf-8"))
return {
node.value.value
for node in ast.walk(tree)
if isinstance(node, ast.keyword)
and node.arg == "translation_key"
and isinstance(node.value, ast.Constant)
and isinstance(node.value.value, str)
}
@pytest.fixture(name="strings")
def strings_fixture() -> dict:
return json.loads((COMPONENT / "strings.json").read_text(encoding="utf-8"))
def test_en_matches_strings(strings):
"""Custom integrations read translations/en.json; it must not drift."""
english = json.loads(
(COMPONENT / "translations" / "en.json").read_text(encoding="utf-8")
)
assert english == strings
@pytest.mark.parametrize("platform,filename", PLATFORMS.items())
def test_every_entity_has_a_name(strings, platform, filename):
missing = declared_keys(filename) - set(strings["entity"][platform])
assert not missing, f"{platform} keys with no translation: {sorted(missing)}"
@pytest.mark.parametrize("platform,filename", PLATFORMS.items())
def test_no_orphaned_translations(strings, platform, filename):
orphans = set(strings["entity"][platform]) - declared_keys(filename)
assert not orphans, f"{platform} translations with no entity: {sorted(orphans)}"
def test_manifest_is_well_formed():
manifest = json.loads((COMPONENT / "manifest.json").read_text(encoding="utf-8"))
for key in ("domain", "name", "version", "documentation", "issue_tracker"):
assert manifest.get(key), f"manifest missing {key}"
assert manifest["domain"] == "freshharvest"
# A private forge URL would be unreachable for anyone installing this.
for key in ("documentation", "issue_tracker"):
assert manifest[key].startswith("https://github.com/"), (
f"{key} must be a public URL, got {manifest[key]}"
)