Expose every order component as its own entity and prepare for release
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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Fresh Harvest",
|
||||
"content_in_root": false,
|
||||
"render_readme": true,
|
||||
"homeassistant": "2025.2.0"
|
||||
}
|
||||
Executable
+66
@@ -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"
|
||||
Vendored
+26
-15
@@ -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
@@ -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
|
||||
|
||||
@@ -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]}"
|
||||
)
|
||||
Reference in New Issue
Block a user