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/),
|
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).
|
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
|
## [0.2.0] - 2026-08-03
|
||||||
|
|
||||||
### Added
|
### 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/),
|
Unofficial Home Assistant integration for [Fresh Harvest](https://freshharvest.com/),
|
||||||
the Georgia local-produce delivery subscription.
|
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
|
## 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.
|
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
|
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
|
## How it works
|
||||||
|
|
||||||
@@ -37,13 +93,14 @@ Login is a two-step handshake:
|
|||||||
an empty `Redirect`, yielding an `fh_session_authenticated` cookie.
|
an empty `Redirect`, yielding an `fh_session_authenticated` cookie.
|
||||||
|
|
||||||
The tokens are bound to the cookie issued by step 1, so both requests must
|
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,
|
`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
|
## 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
|
- **HTTP status means nothing.** Every `/p/*` path returns 200, including
|
||||||
invented ones. Signed-in state is detected by the presence of a Sign Out
|
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
|
locked cart — the one past its cutoff and arriving next. Treating it as
|
||||||
"skipped" reports the wrong delivery as cancelled. The reliable signal for
|
"skipped" reports the wrong delivery as cancelled. The reliable signal for
|
||||||
"can still be changed" is a non-empty `.cart-customize-wrapper`.
|
"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
|
## Dashboard
|
||||||
|
|
||||||
[examples/dashboard-view.yaml](examples/dashboard-view.yaml) is a ready-made tab
|
[examples/dashboard-view.yaml](examples/dashboard-view.yaml) is a ready-made tab
|
||||||
for these sensors — a countdown heading ("Arriving tomorrow"), tiles for the
|
— a countdown heading ("Arriving tomorrow"), tiles for the cost breakdown, the
|
||||||
date, total and item count, the full box contents rendered from the attributes,
|
full box contents rendered from the attributes, and the still-changeable order.
|
||||||
and the still-changeable order with a link back to the portal. Paste it under
|
Paste it under `views:` in the raw configuration editor.
|
||||||
`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.
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -75,10 +127,16 @@ pip install beautifulsoup4 pytest
|
|||||||
pytest tests/
|
pytest tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
The fixture is synthetic but mirrors the real markup; the live page carries the
|
The fixture is synthetic but mirrors the real markup, with placeholder cart IDs
|
||||||
account holder's name, address, and phone number, so it is not committed.
|
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
|
## Disclaimer
|
||||||
|
|
||||||
Unofficial and unaffiliated. Polls once every 6 hours; please do not lower that
|
Unofficial and unaffiliated — not endorsed by or supported by Fresh Harvest.
|
||||||
— this is a small business's website, not an API.
|
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 .api import FreshHarvestClient
|
||||||
from .coordinator import FreshHarvestCoordinator
|
from .coordinator import FreshHarvestCoordinator
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||||
|
|
||||||
type FreshHarvestConfigEntry = ConfigEntry[FreshHarvestCoordinator]
|
type FreshHarvestConfigEntry = ConfigEntry[FreshHarvestCoordinator]
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,12 @@ class DeliveryOrder:
|
|||||||
subtotal: float | None = None
|
subtotal: float | None = None
|
||||||
tax: float | None = None
|
tax: float | None = None
|
||||||
delivery_fee: float | None = None
|
delivery_fee: float | None = None
|
||||||
|
driver_tip: float | None = None
|
||||||
total: 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"
|
# 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.
|
# or "Shop thru Sunday 8/9". Empty once the order is locked for packing.
|
||||||
shop_window: str | None = None
|
shop_window: str | None = None
|
||||||
@@ -126,6 +131,9 @@ class AccountSnapshot:
|
|||||||
|
|
||||||
next_delivery: date | None = None
|
next_delivery: date | None = None
|
||||||
delivery_day: str | 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)
|
orders: list[DeliveryOrder] = field(default_factory=list)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -262,9 +270,30 @@ def parse_dashboard(html: str) -> AccountSnapshot:
|
|||||||
order.tax = amount
|
order.tax = amount
|
||||||
elif key.startswith("delivery"):
|
elif key.startswith("delivery"):
|
||||||
order.delivery_fee = amount
|
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)
|
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:
|
if not snapshot.orders and snapshot.next_delivery is None:
|
||||||
raise FreshHarvestError("dashboard markup not recognised")
|
raise FreshHarvestError("dashboard markup not recognised")
|
||||||
return snapshot
|
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",
|
"domain": "freshharvest",
|
||||||
"name": "Fresh Harvest",
|
"name": "Fresh Harvest",
|
||||||
"codeowners": [
|
"codeowners": ["@sudolulo"],
|
||||||
"@flan"
|
|
||||||
],
|
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://git.onetick.ninja/flan/ha-freshharvest",
|
"documentation": "https://github.com/sudolulo/ha-freshharvest",
|
||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"issue_tracker": "https://git.onetick.ninja/flan/ha-freshharvest/issues",
|
"issue_tracker": "https://github.com/sudolulo/ha-freshharvest/issues",
|
||||||
"requirements": [
|
"requirements": ["beautifulsoup4>=4.12"],
|
||||||
"beautifulsoup4>=4.12"
|
"version": "0.3.0"
|
||||||
],
|
|
||||||
"version": "0.2.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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -13,17 +18,17 @@ from homeassistant.components.sensor import (
|
|||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|
||||||
|
|
||||||
from . import FreshHarvestConfigEntry
|
from . import FreshHarvestConfigEntry
|
||||||
from .api import AccountSnapshot, DeliveryOrder, OrderItem
|
from .api import DeliveryOrder, OrderItem
|
||||||
from .const import DOMAIN
|
|
||||||
from .coordinator import FreshHarvestCoordinator
|
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'."""
|
"""Render one line, e.g. '4 Complete Recovery Smoothie 15.2 fl oz — $17.96'."""
|
||||||
line = " ".join(
|
line = " ".join(
|
||||||
part for part in (str(item.quantity or ""), item.name, item.unit) if part
|
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
|
return line
|
||||||
|
|
||||||
|
|
||||||
def _items_attrs(order: DeliveryOrder | None) -> dict[str, Any] | None:
|
def _contents_attrs(order: DeliveryOrder) -> dict[str, Any]:
|
||||||
if order is None:
|
|
||||||
return None
|
|
||||||
return {
|
return {
|
||||||
"box": order.box_name,
|
"box": order.box_name,
|
||||||
"box_price": order.box_price,
|
"produce": [format_item(i) for i in order.items],
|
||||||
"produce": [_format_item(i) for i in order.items],
|
"add_ons": [format_item(a, with_price=True) for a in order.addons],
|
||||||
"add_ons": [_format_item(a, with_price=True) for a in order.addons],
|
"produce_count": len(order.items),
|
||||||
"add_ons_total": order.addons_total,
|
"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):
|
class FreshHarvestSensorDescription(SensorEntityDescription):
|
||||||
"""Describes a Fresh Harvest sensor."""
|
"""Describes a Fresh Harvest sensor."""
|
||||||
|
|
||||||
value_fn: Callable[[AccountSnapshot], Any]
|
scope: Scope = "next_order"
|
||||||
attrs_fn: Callable[[AccountSnapshot], dict[str, Any] | None] | None = None
|
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, ...] = (
|
SENSORS: tuple[FreshHarvestSensorDescription, ...] = (
|
||||||
|
# --- the account itself -------------------------------------------------
|
||||||
FreshHarvestSensorDescription(
|
FreshHarvestSensorDescription(
|
||||||
key="next_delivery",
|
key="next_delivery",
|
||||||
translation_key="next_delivery",
|
translation_key="next_delivery",
|
||||||
|
scope="account",
|
||||||
device_class=SensorDeviceClass.DATE,
|
device_class=SensorDeviceClass.DATE,
|
||||||
value_fn=lambda s: s.next_delivery,
|
value_fn=lambda s: s.next_delivery,
|
||||||
attrs_fn=lambda s: {
|
attrs_fn=lambda s: {
|
||||||
"delivery_day": s.delivery_day,
|
"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(
|
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",
|
key="next_delivery_total",
|
||||||
translation_key="next_delivery_total",
|
translation_key="next_delivery_total",
|
||||||
device_class=SensorDeviceClass.MONETARY,
|
value_fn=lambda o: o.total,
|
||||||
state_class=SensorStateClass.TOTAL,
|
attrs_fn=_total_attrs,
|
||||||
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,
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
FreshHarvestSensorDescription(
|
_money(
|
||||||
key="next_delivery_addons_total",
|
key="next_delivery_subtotal",
|
||||||
translation_key="next_delivery_addons_total",
|
translation_key="next_delivery_subtotal",
|
||||||
device_class=SensorDeviceClass.MONETARY,
|
value_fn=lambda o: o.subtotal,
|
||||||
state_class=SensorStateClass.TOTAL,
|
),
|
||||||
native_unit_of_measurement="USD",
|
_money(
|
||||||
value_fn=lambda s: s.next_order.addons_total if s.next_order else None,
|
key="next_delivery_box_price",
|
||||||
attrs_fn=lambda s: None
|
translation_key="next_delivery_box_price",
|
||||||
if s.next_order is None
|
value_fn=lambda o: o.box_price,
|
||||||
else {
|
),
|
||||||
"box_price": s.next_order.box_price,
|
_money(
|
||||||
"add_ons": [
|
key="next_delivery_add_ons",
|
||||||
_format_item(a, with_price=True) for a in s.next_order.addons
|
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(
|
FreshHarvestSensorDescription(
|
||||||
key="next_delivery_items",
|
key="next_delivery_items",
|
||||||
translation_key="next_delivery_items",
|
translation_key="next_delivery_items",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
native_unit_of_measurement="items",
|
native_unit_of_measurement="items",
|
||||||
value_fn=lambda s: len(s.next_order.all_items) if s.next_order else None,
|
icon="mdi:basket-check",
|
||||||
attrs_fn=lambda s: _items_attrs(s.next_order),
|
value_fn=lambda o: len(o.all_items),
|
||||||
|
attrs_fn=_contents_attrs,
|
||||||
),
|
),
|
||||||
|
# --- the order that can still be changed --------------------------------
|
||||||
FreshHarvestSensorDescription(
|
FreshHarvestSensorDescription(
|
||||||
key="open_order_delivery",
|
key="open_order_delivery",
|
||||||
translation_key="open_order_delivery",
|
translation_key="open_order_delivery",
|
||||||
|
scope="open_order",
|
||||||
device_class=SensorDeviceClass.DATE,
|
device_class=SensorDeviceClass.DATE,
|
||||||
value_fn=lambda s: s.open_order.delivery_date if s.open_order else None,
|
value_fn=lambda o: o.delivery_date,
|
||||||
attrs_fn=lambda s: None
|
attrs_fn=lambda o: {"box": o.box_name},
|
||||||
if s.open_order is None
|
),
|
||||||
else {
|
_money(
|
||||||
"total": s.open_order.total,
|
key="open_order_total",
|
||||||
"box": s.open_order.box_name,
|
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(
|
FreshHarvestSensorDescription(
|
||||||
key="shop_window",
|
key="shop_window",
|
||||||
translation_key="shop_window",
|
translation_key="shop_window",
|
||||||
value_fn=lambda s: (s.open_order.shop_window if s.open_order else None)
|
scope="open_order",
|
||||||
or "closed",
|
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."""
|
"""A value read off the Fresh Harvest dashboard."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
|
||||||
entity_description: FreshHarvestSensorDescription
|
entity_description: FreshHarvestSensorDescription
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -148,25 +197,23 @@ class FreshHarvestSensor(CoordinatorEntity[FreshHarvestCoordinator], SensorEntit
|
|||||||
entry: FreshHarvestConfigEntry,
|
entry: FreshHarvestConfigEntry,
|
||||||
description: FreshHarvestSensorDescription,
|
description: FreshHarvestSensorDescription,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator, entry, description.key)
|
||||||
self.entity_description = description
|
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
|
@property
|
||||||
def native_value(self) -> Any:
|
def native_value(self) -> Any:
|
||||||
"""Return the sensor value."""
|
"""Return the sensor value, or None when its order does not exist."""
|
||||||
return self.entity_description.value_fn(self.coordinator.data)
|
target = self.target(self.entity_description.scope)
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
return self.entity_description.value_fn(target)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||||
"""Return the sensor's extra attributes."""
|
"""Return the sensor's extra attributes."""
|
||||||
if self.entity_description.attrs_fn is None:
|
if self.entity_description.attrs_fn is None:
|
||||||
return 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": {
|
"next_delivery": {
|
||||||
"name": "Next delivery"
|
"name": "Next delivery"
|
||||||
},
|
},
|
||||||
|
"delivery_day": {
|
||||||
|
"name": "Delivery day"
|
||||||
|
},
|
||||||
"next_delivery_total": {
|
"next_delivery_total": {
|
||||||
"name": "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"
|
"name": "Next delivery add-ons"
|
||||||
},
|
},
|
||||||
|
"next_delivery_tax": {
|
||||||
|
"name": "Next delivery tax"
|
||||||
|
},
|
||||||
|
"next_delivery_delivery_fee": {
|
||||||
|
"name": "Next delivery fee"
|
||||||
|
},
|
||||||
"next_delivery_items": {
|
"next_delivery_items": {
|
||||||
"name": "Next delivery items"
|
"name": "Next delivery items"
|
||||||
},
|
},
|
||||||
"open_order_delivery": {
|
"open_order_delivery": {
|
||||||
"name": "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": {
|
"shop_window": {
|
||||||
"name": "Shopping window"
|
"name": "Shopping window"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"binary_sensor": {
|
||||||
|
"order_open": {
|
||||||
|
"name": "Order open"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,21 +22,47 @@
|
|||||||
"next_delivery": {
|
"next_delivery": {
|
||||||
"name": "Next delivery"
|
"name": "Next delivery"
|
||||||
},
|
},
|
||||||
|
"delivery_day": {
|
||||||
|
"name": "Delivery day"
|
||||||
|
},
|
||||||
"next_delivery_total": {
|
"next_delivery_total": {
|
||||||
"name": "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"
|
"name": "Next delivery add-ons"
|
||||||
},
|
},
|
||||||
|
"next_delivery_tax": {
|
||||||
|
"name": "Next delivery tax"
|
||||||
|
},
|
||||||
|
"next_delivery_delivery_fee": {
|
||||||
|
"name": "Next delivery fee"
|
||||||
|
},
|
||||||
"next_delivery_items": {
|
"next_delivery_items": {
|
||||||
"name": "Next delivery items"
|
"name": "Next delivery items"
|
||||||
},
|
},
|
||||||
"open_order_delivery": {
|
"open_order_delivery": {
|
||||||
"name": "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": {
|
"shop_window": {
|
||||||
"name": "Shopping window"
|
"name": "Shopping window"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"binary_sensor": {
|
||||||
|
"order_open": {
|
||||||
|
"name": "Order open"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# Fresh Harvest dashboard view.
|
# Fresh Harvest dashboard view.
|
||||||
#
|
#
|
||||||
# This is the tab shipped alongside the integration: three sections covering the
|
# Four sections: the next delivery, its cost broken down per line, what is in
|
||||||
# next delivery, what is in the box, and the order that can still be changed.
|
# the box, and the order that can still be changed.
|
||||||
#
|
#
|
||||||
# To use it, open your dashboard, choose "Edit dashboard" -> "Raw configuration
|
# To use it, open your dashboard, choose "Edit dashboard" -> "Raw configuration
|
||||||
# editor", and paste this under `views:`. It only needs the six sensors the
|
# editor", and paste this under `views:`. It needs the entities this
|
||||||
# integration creates.
|
# integration creates and nothing else — no custom cards.
|
||||||
|
|
||||||
type: sections
|
type: sections
|
||||||
max_columns: 4
|
max_columns: 4
|
||||||
@@ -23,8 +23,7 @@ sections:
|
|||||||
{%- set d = states('sensor.fresh_harvest_next_delivery') -%}
|
{%- set d = states('sensor.fresh_harvest_next_delivery') -%}
|
||||||
{%- set box = state_attr('sensor.fresh_harvest_next_delivery_items', 'box') -%}
|
{%- set box = state_attr('sensor.fresh_harvest_next_delivery_items', 'box') -%}
|
||||||
{%- if d not in ['unknown', 'unavailable', 'none'] -%}
|
{%- if d not in ['unknown', 'unavailable', 'none'] -%}
|
||||||
{%- set days = (d | as_datetime).date() - now().date() -%}
|
{%- set n = ((d | as_datetime).date() - now().date()).days -%}
|
||||||
{%- set n = days.days -%}
|
|
||||||
## {% if n < 0 %}Delivered{% elif n == 0 %}Arriving today{% elif n == 1 %}Arriving tomorrow{% else %}Arriving in {{ n }} days{% endif %}
|
## {% 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 %}
|
{{ (d | as_datetime).strftime('%A, %B %-d') }}{% if box %} · {{ box }}{% endif %}
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
@@ -40,16 +39,50 @@ sections:
|
|||||||
name: Order total
|
name: Order total
|
||||||
icon: mdi:cash-multiple
|
icon: mdi:cash-multiple
|
||||||
color: teal
|
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
|
- type: tile
|
||||||
entity: sensor.fresh_harvest_next_delivery_add_ons
|
entity: sensor.fresh_harvest_next_delivery_add_ons
|
||||||
name: Add-ons
|
name: Add-ons
|
||||||
icon: mdi:cart-plus
|
icon: mdi:cart-plus
|
||||||
color: purple
|
color: purple
|
||||||
- type: tile
|
- type: tile
|
||||||
entity: sensor.fresh_harvest_next_delivery_items
|
entity: sensor.fresh_harvest_next_delivery_subtotal
|
||||||
name: Items
|
name: Subtotal
|
||||||
icon: mdi:basket-check
|
icon: mdi:calculator
|
||||||
color: light-green
|
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
|
- type: grid
|
||||||
cards:
|
cards:
|
||||||
- type: heading
|
- type: heading
|
||||||
@@ -60,10 +93,10 @@ sections:
|
|||||||
content: |-
|
content: |-
|
||||||
{%- set produce = state_attr('sensor.fresh_harvest_next_delivery_items', 'produce') or [] -%}
|
{%- 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 = 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 = states('sensor.fresh_harvest_next_delivery_box_price') | float(0) -%}
|
||||||
{%- set box_price = state_attr('sensor.fresh_harvest_next_delivery_items', 'box_price') -%}
|
{%- set addons_total = states('sensor.fresh_harvest_next_delivery_add_ons') | float(0) -%}
|
||||||
{%- if produce -%}
|
{%- if produce -%}
|
||||||
**In the box**{% if box_price %} · ${{ '%.2f' | format(box_price) }}{% endif %}
|
**In the box** · ${{ '%.2f' | format(box_price) }}
|
||||||
{% for i in produce %}
|
{% for i in produce %}
|
||||||
- {{ i }}
|
- {{ i }}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -71,7 +104,7 @@ sections:
|
|||||||
_Box contents not assigned yet._
|
_Box contents not assigned yet._
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{% if addons %}
|
{% if addons %}
|
||||||
**Add-ons**{% if addons_total %} · ${{ '%.2f' | format(addons_total) }}{% endif %}
|
**Add-ons** · ${{ '%.2f' | format(addons_total) }}
|
||||||
{% for a in addons %}
|
{% for a in addons %}
|
||||||
- {{ a }}
|
- {{ a }}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -82,6 +115,11 @@ sections:
|
|||||||
heading: Still Open
|
heading: Still Open
|
||||||
heading_style: title
|
heading_style: title
|
||||||
icon: mdi:cart-arrow-right
|
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
|
- type: tile
|
||||||
entity: sensor.fresh_harvest_open_order_delivery
|
entity: sensor.fresh_harvest_open_order_delivery
|
||||||
name: Delivery
|
name: Delivery
|
||||||
@@ -92,6 +130,21 @@ sections:
|
|||||||
name: Shopping window
|
name: Shopping window
|
||||||
icon: mdi:clock-alert-outline
|
icon: mdi:clock-alert-outline
|
||||||
color: orange
|
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
|
- type: markdown
|
||||||
content: |-
|
content: |-
|
||||||
Once an order passes its cutoff it locks for packing, and the following week's cart opens.
|
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>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
<div class='nav-main-cart'>
|
<div class='nav-main-cart'>
|
||||||
<div class='cart-selector-options-wrapper' data-cart-select='2772590'>
|
<div class='cart-selector-options-wrapper' data-cart-select='1000001'>
|
||||||
<div data-cart-select='2772590'>Tue 8/4</div>
|
<div data-cart-select='1000001'>Tue 8/4</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='cart-selector-options-wrapper' data-cart-select='2778336'>
|
<div class='cart-selector-options-wrapper' data-cart-select='1000002'>
|
||||||
<div data-cart-select='2778336'>Tue 8/11</div>
|
<div data-cart-select='1000002'>Tue 8/11</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Open cart: still customizable, contents not yet assigned. -->
|
<!-- 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'>
|
<div class='cart-basket-columns'>
|
||||||
<h6>Georgia Grown Small Box</h6>
|
<h6>Georgia Grown Small Box</h6>
|
||||||
<div class='item-total'>$33.00</div>
|
<div class='item-total'>$33.00</div>
|
||||||
<div class='cart-customize-wrapper'><div>Shop tomorrow</div></div>
|
<div class='cart-customize-wrapper'><div>Shop tomorrow</div></div>
|
||||||
</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'>
|
<div class='cart-summary'>
|
||||||
<span class='summary-item label'>Subtotal</span><span class='summary-item value'>$33.00</span>
|
<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'>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'>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'>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 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Locked cart: past its cutoff, arriving next, contents final. -->
|
<!-- 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'>
|
<div class='cart-basket-columns'>
|
||||||
<h6>Georgia Grown Small Box</h6>
|
<h6>Georgia Grown Small Box</h6>
|
||||||
<div class='item-total'>$33.00</div>
|
<div class='item-total'>$33.00</div>
|
||||||
@@ -55,7 +64,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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' />
|
<img class='cart-item-img' src='https://cdn.freshharvest.com/x.jpg' alt='Black Mission Figs' />
|
||||||
<div class='item-details'>
|
<div class='item-details'>
|
||||||
<span class='item-name'><div>Black Mission Figs</div></span>
|
<span class='item-name'><div>Black Mission Figs</div></span>
|
||||||
@@ -64,14 +73,14 @@
|
|||||||
<div class='item-order-wrapper'>
|
<div class='item-order-wrapper'>
|
||||||
<div class='item-order item-order-disabled'>
|
<div class='item-order item-order-disabled'>
|
||||||
<div class='btn-round item-order-remove qv-ignore'></div>
|
<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 class='btn-round item-order-add qv-ignore'></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Multi-quantity add-on: item-total is the EXTENDED price, 4 x $4.49. -->
|
<!-- 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' />
|
<img class='cart-item-img' src='https://cdn.freshharvest.com/y.jpg' alt='Complete Recovery Smoothie' />
|
||||||
<div class='item-details'>
|
<div class='item-details'>
|
||||||
<span class='item-name'><div>Complete Recovery Smoothie</div></span>
|
<span class='item-name'><div>Complete Recovery Smoothie</div></span>
|
||||||
@@ -80,13 +89,13 @@
|
|||||||
<div class='item-order-wrapper'>
|
<div class='item-order-wrapper'>
|
||||||
<div class='item-order item-order-disabled'>
|
<div class='item-order item-order-disabled'>
|
||||||
<div class='btn-round item-order-remove qv-ignore'></div>
|
<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 class='btn-round item-order-add qv-ignore'></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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' />
|
<img class='cart-item-img' src='https://cdn.freshharvest.com/z.jpg' alt='Organic Fruit Punch Juice Boxes' />
|
||||||
<div class='item-details'>
|
<div class='item-details'>
|
||||||
<span class='item-name'><div>Organic Fruit Punch Juice Boxes</div></span>
|
<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-wrapper'>
|
||||||
<div class='item-order item-order-disabled'>
|
<div class='item-order item-order-disabled'>
|
||||||
<div class='btn-round item-order-remove qv-ignore'></div>
|
<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 class='btn-round item-order-add qv-ignore'></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id='OrderTotals-2772590'>
|
<div id='OrderTotals-1000001'>
|
||||||
<div class='cart-summary'>
|
<div class='cart-summary'>
|
||||||
<!-- Self-consistent: box 33.00 + add-ons 39.93 = 72.93 subtotal, which is
|
<!-- 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. -->
|
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'>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'>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'>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>
|
||||||
</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.
|
class `cart-contents-skipped`, which does not mean the user skipped it.
|
||||||
"""
|
"""
|
||||||
order = snapshot.next_order
|
order = snapshot.next_order
|
||||||
assert order.delivery_id == "2772590"
|
assert order.delivery_id == "1000001"
|
||||||
assert order.delivery_date == date(2026, 8, 4)
|
assert order.delivery_date == date(2026, 8, 4)
|
||||||
assert order.is_open is False
|
assert order.is_open is False
|
||||||
|
|
||||||
@@ -71,7 +71,9 @@ def test_next_order_totals_and_contents(snapshot):
|
|||||||
order = snapshot.next_order
|
order = snapshot.next_order
|
||||||
assert order.box_name == "Georgia Grown Small Box"
|
assert order.box_name == "Georgia Grown Small Box"
|
||||||
assert (order.subtotal, order.tax, order.delivery_fee) == (72.93, 2.19, 0.0)
|
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] == [
|
assert [(i.quantity, i.name, i.unit) for i in order.items] == [
|
||||||
(1, "Bolero Carrots", ".5 lb"),
|
(1, "Bolero Carrots", ".5 lb"),
|
||||||
(2, "Georgia Peaches", "6 count"),
|
(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):
|
def test_open_order(snapshot):
|
||||||
order = snapshot.open_order
|
order = snapshot.open_order
|
||||||
assert order.delivery_id == "2778336"
|
assert order.delivery_id == "1000002"
|
||||||
assert order.delivery_date == date(2026, 8, 11)
|
assert order.delivery_date == date(2026, 8, 11)
|
||||||
assert order.is_open is True
|
assert order.is_open is True
|
||||||
assert order.shop_window == "Shop tomorrow"
|
assert order.shop_window == "Shop tomorrow"
|
||||||
assert order.total == 39.98
|
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):
|
def test_driver_tip_placeholder_is_not_money(snapshot):
|
||||||
"""'Add Tip' sits in a value slot but is not an amount."""
|
"""'Add Tip' sits in a value slot but is not an amount."""
|
||||||
assert api._money("Add Tip") is None
|
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