Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e6499a8e | ||
|
|
bf01fd6eb0 | ||
|
|
b4e7ef4b70 | ||
|
|
0a5441779a | ||
|
|
8051cc7465 | ||
|
|
e31f84e40b | ||
|
|
1001d019a0 | ||
|
|
f04dadf21a | ||
|
|
70c67901b8 | ||
|
|
4a8c4bebd7 | ||
|
|
957505209a | ||
|
|
fa09ac6d5d | ||
|
|
73a3409353 | ||
|
|
6ec06ee31a | ||
|
|
e7427082e3 | ||
|
|
0a218acd45 | ||
|
|
5e9d721a0f | ||
|
|
551d3240a2 |
@@ -0,0 +1,117 @@
|
||||
name: Signed-in compatibility
|
||||
|
||||
# compat.yml checks what anyone can see on freshharvest.com. Everything that has
|
||||
# actually broken so far sat behind the login, and every one of those breaks was
|
||||
# SILENT: subscription rows moved and the integration reported 0 subscriptions,
|
||||
# hold dates stopped being ISO and it reported 0 holds. A sensor reading 0 looks
|
||||
# like an account with nothing in it, so nobody notices.
|
||||
#
|
||||
# tools/compat_auth.py signs in, read-only, and checks the markup the sensors and
|
||||
# controls are parsed from. It never posts to a write endpoint.
|
||||
#
|
||||
# WHERE IT RUNS: only on the maintainer's own forge, where the account
|
||||
# credentials are repository secrets. The GitHub mirror and forks skip it (the
|
||||
# job's `if:`); they have no credentials, and a daily red run would be noise.
|
||||
#
|
||||
# THE RUN LOG IS PUBLIC. The script prints one pass/fail label per assumption and
|
||||
# nothing read from the account. Keep it that way: no `set -x`, never echo an
|
||||
# env var, and hand secrets to steps through `env:` only, never inline in `run:`.
|
||||
#
|
||||
# Secrets: FRESHHARVEST_EMAIL and FRESHHARVEST_PASSWORD (the account), NTFY_URL
|
||||
# (the full ntfy topic URL) and NTFY_TOKEN (an access token for that topic).
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 11:41 UTC is 07:41 EDT (06:41 EST): before the day's first Home Assistant
|
||||
# refresh, so a break is known before anyone reads a sensor quietly showing 0.
|
||||
- cron: "41 11 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
compat-auth:
|
||||
name: signed-in markup check
|
||||
# Not GitHub, and this repository: skips the mirror and every fork.
|
||||
if: ${{ github.server_url != 'https://github.com' && github.repository == 'flan/ha-freshharvest' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
# Exit 5: every assumption holds. 10: drift. Anything else: it could not run.
|
||||
- name: Check the markup behind the login
|
||||
id: check
|
||||
shell: bash
|
||||
env:
|
||||
FH_EMAIL: ${{ secrets.FRESHHARVEST_EMAIL }}
|
||||
FH_PASSWORD: ${{ secrets.FRESHHARVEST_PASSWORD }}
|
||||
run: |
|
||||
report="${RUNNER_TEMP:-/tmp}/compat-auth-report.txt"
|
||||
rc=0
|
||||
# Well inside the job's 10 minutes, so the notify step still gets to run.
|
||||
timeout 7m python tools/compat_auth.py > "$report" 2>&1 || rc=$?
|
||||
if [ "$rc" -eq 124 ]; then
|
||||
printf '\ntimed out after 7 minutes\n' >> "$report"
|
||||
fi
|
||||
cat "$report"
|
||||
echo "rc=$rc" >> "$GITHUB_OUTPUT"
|
||||
case "$rc" in
|
||||
5) exit 0 ;;
|
||||
10) echo "::error::freshharvest.com markup has drifted; see the report above"
|
||||
exit 1 ;;
|
||||
*) echo "::error::the check could not run (exit $rc)"
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
# Runs on drift, on a failed check, and on any earlier step failing.
|
||||
- name: Push the report to ntfy
|
||||
if: ${{ failure() }}
|
||||
shell: bash
|
||||
env:
|
||||
NTFY_URL: ${{ secrets.NTFY_URL }}
|
||||
NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }}
|
||||
RC: ${{ steps.check.outputs.rc }}
|
||||
# Gitea addresses a run's page by its per-repository run number.
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}
|
||||
run: |
|
||||
if [ -z "$NTFY_URL" ] || [ -z "$NTFY_TOKEN" ]; then
|
||||
echo "::error::the NTFY_URL and NTFY_TOKEN secrets must both be set"
|
||||
exit 1
|
||||
fi
|
||||
report="${RUNNER_TEMP:-/tmp}/compat-auth-report.txt"
|
||||
if [ ! -s "$report" ]; then
|
||||
echo "No report: a step before the check failed." > "$report"
|
||||
fi
|
||||
if [ "$RC" = "10" ]; then
|
||||
title="Fresh Harvest markup drift"
|
||||
priority=default
|
||||
tags=warning
|
||||
else
|
||||
title="Fresh Harvest signed-in check could not run"
|
||||
priority=high
|
||||
tags=rotating_light
|
||||
fi
|
||||
# -s without -S: a curl error message would name the ntfy host in this public log.
|
||||
# The response body echoes the report, so it goes to /dev/null.
|
||||
curl_rc=0
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 --retry 2 \
|
||||
-H "Authorization: Bearer $NTFY_TOKEN" \
|
||||
-H "Title: $title" \
|
||||
-H "Priority: $priority" \
|
||||
-H "Tags: $tags" \
|
||||
-H "Click: $RUN_URL" \
|
||||
--data-binary "@$report" \
|
||||
"$NTFY_URL") || curl_rc=$?
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "::error::ntfy push failed (HTTP ${code:-none}, curl exit $curl_rc)"
|
||||
exit 1
|
||||
fi
|
||||
echo "report pushed to ntfy"
|
||||
@@ -9,11 +9,11 @@ name: Upstream compatibility
|
||||
# live site daily, refreshing the matrix in README.md and opening an issue when
|
||||
# something breaks.
|
||||
#
|
||||
# SCOPE: unauthenticated surface only. The authenticated contract (dashboard
|
||||
# markup, cart hashes, skip popups, subscribe forms) needs a real session, and
|
||||
# the only way to give public CI one is to park a personal grocery account's
|
||||
# password in repo secrets. Not worth it for a drift check — that half belongs in
|
||||
# a fleet job on a host that already holds credentials.
|
||||
# SCOPE: unauthenticated surface only, so this runs anywhere, the GitHub mirror
|
||||
# included. The authenticated contract (dashboard markup, cart hashes, skip
|
||||
# popups, subscribe forms) needs a real session: compat-auth.yml checks that
|
||||
# half, and runs only on the maintainer's forge, where the account credentials
|
||||
# are repository secrets.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -26,9 +26,9 @@ on:
|
||||
- ".github/workflows/compat.yml"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
# Read-only on contents: this workflow reports, it does not write to the repo.
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
compat:
|
||||
@@ -48,42 +48,18 @@ jobs:
|
||||
echo "failures=$?" >> "$GITHUB_OUTPUT"
|
||||
cat matrix.md
|
||||
|
||||
- name: Refresh the matrix in README
|
||||
- name: Publish the matrix to the run summary
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import pathlib, re, datetime
|
||||
matrix = pathlib.Path("matrix.md").read_text().strip()
|
||||
stamp = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d")
|
||||
block = f"<!-- COMPAT:START -->\n_Last checked {stamp}._\n\n{matrix}\n<!-- COMPAT:END -->"
|
||||
readme = pathlib.Path("README.md")
|
||||
text = readme.read_text()
|
||||
new = re.sub(r"<!-- COMPAT:START -->.*<!-- COMPAT:END -->", block, text, flags=re.S)
|
||||
if new != text:
|
||||
readme.write_text(new)
|
||||
print("README matrix updated")
|
||||
else:
|
||||
print("no change")
|
||||
PY
|
||||
|
||||
- name: Commit the refreshed matrix
|
||||
run: |
|
||||
if git diff --quiet README.md; then
|
||||
echo "nothing to commit"; exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add README.md
|
||||
git commit -m "Refresh the upstream compatibility matrix"
|
||||
# GitHub is a MIRROR of Gitea, never a source of truth, so this must not
|
||||
# push. It opens a PR instead; merge it on the canonical forge.
|
||||
BRANCH="compat/refresh-$(date -u +%Y%m%d)"
|
||||
git checkout -b "$BRANCH"
|
||||
git push -f origin "$BRANCH"
|
||||
gh pr list --head "$BRANCH" --state open --json number -q '.[0].number' | grep -q . \
|
||||
|| gh pr create --head "$BRANCH" --title "Refresh the upstream compatibility matrix" \
|
||||
--body "Automated: freshharvest.com assumption check. See the matrix in README."
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
# Deliberately does NOT commit. GitHub is a read-only mirror of Gitea,
|
||||
# so anything a bot pushes here is clobbered by the next sync, and
|
||||
# opening a PR needs a repo setting that a mirror should not depend
|
||||
# on. The badge should mean "is upstream still compatible", not "did
|
||||
# the bot manage its own bookkeeping".
|
||||
{
|
||||
echo "## Upstream compatibility"
|
||||
echo
|
||||
cat matrix.md
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Open an issue when the site has drifted
|
||||
if: steps.check.outputs.failures != '0'
|
||||
|
||||
@@ -8,9 +8,27 @@ on:
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
# hassfest and HACS run on GitHub ONLY, and are skipped on the Gitea mirror of this repo.
|
||||
# Neither can work there, for reasons no input or secret changes:
|
||||
#
|
||||
# hassfest is a Docker-container action that bind-mounts $GITHUB_WORKSPACE. The Gitea runner
|
||||
# executes the job inside a container against a SEPARATE docker-in-docker daemon, so that path
|
||||
# is resolved on the daemon's filesystem rather than the job's; docker helpfully creates an
|
||||
# empty directory and mounts that, and hassfest then correctly reports "No integrations found!"
|
||||
# about a tree it was handed empty. It needs no token, so no credential fixes it.
|
||||
#
|
||||
# HACS asks the github.com API about ${{ github.repository }}. On Gitea that is
|
||||
# "flan/ha-freshharvest", which exists only on Gitea — hence the 401. The GitHub mirror is
|
||||
# sudolulo/ha-freshharvest, and hacs/action has no input to redirect the lookup.
|
||||
#
|
||||
# Nothing is lost by skipping them here: github.com/sudolulo/ha-freshharvest is a live mirror and
|
||||
# runs both jobs green on every push. The condition is written as "not Gitea" rather than
|
||||
# "is GitHub" on purpose, so an unexpected server_url still RUNS the checks instead of quietly
|
||||
# dropping them.
|
||||
jobs:
|
||||
hassfest:
|
||||
name: hassfest
|
||||
if: ${{ github.server_url != 'https://git.arch.fyi' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -18,6 +36,7 @@ jobs:
|
||||
|
||||
hacs:
|
||||
name: HACS
|
||||
if: ${{ github.server_url != 'https://git.arch.fyi' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -37,5 +56,5 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: pip install beautifulsoup4 pytest
|
||||
- run: pip install beautifulsoup4 pytest yarl
|
||||
- run: pytest tests/ -q
|
||||
|
||||
@@ -5,6 +5,97 @@ 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.5.1] - 2026-09-21
|
||||
|
||||
No change to the integration itself: the check of the markup behind the login
|
||||
now lives in this repository.
|
||||
|
||||
### Added
|
||||
|
||||
- `tools/compat_auth.py` and a daily `compat-auth.yml` workflow. It signs in,
|
||||
read-only, and checks the 17 assumptions the integration makes about the
|
||||
signed-in pages. Exit 5 means all hold, 10 means drift, and anything else
|
||||
means it could not run; a failed run pushes the report to ntfy. It runs only
|
||||
on the maintainer's forge and is skipped on the GitHub mirror and on forks.
|
||||
Credentials come from the `FH_EMAIL` and `FH_PASSWORD` environment
|
||||
variables, and the output is pass/fail labels only, because the run log is
|
||||
public.
|
||||
- Tests for its credential handling, exit codes and log hygiene.
|
||||
- A README section on the scheduled sign-in check.
|
||||
|
||||
### Changed
|
||||
|
||||
- `compat.yml`, `tools/compat.py` and `docs/internals.md` point at the new
|
||||
workflow for the signed-in half instead of an external job.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A broken relative link to `tools/compat.py` in `docs/internals.md`.
|
||||
|
||||
## [0.5.0] - 2026-08-19
|
||||
|
||||
The integration now wears the Fresh Harvest brand instead of the generic
|
||||
puzzle-piece placeholder.
|
||||
|
||||
### Added
|
||||
|
||||
- Bundled brand images in `custom_components/freshharvest/brand/`: the FH
|
||||
icon (256/512), the wordmark logo, and a dark-theme wordmark variant.
|
||||
Home Assistant 2026.3+ serves these locally, so Settings → Devices &
|
||||
Services shows the real logo with no home-assistant/brands submission
|
||||
(that repository no longer accepts custom integrations anyway).
|
||||
- The Fresh Harvest wordmark at the top of the README (`docs/logo.svg`).
|
||||
|
||||
### Notes
|
||||
|
||||
- Home Assistant older than 2026.3 ignores the `brand/` folder and keeps the
|
||||
placeholder; nothing breaks.
|
||||
- The HACS dashboard still shows its own placeholder — HACS fetches icons
|
||||
from its CDN and does not read local brand images yet
|
||||
(hacs/integration#5171). The integration pages themselves are unaffected.
|
||||
|
||||
## [0.4.1] - 2026-08-04
|
||||
|
||||
### Fixed
|
||||
|
||||
- The produce-box select listed the boxes on offer from inside
|
||||
`async_added_to_hass`, one fetch per box popup. That ran during entity setup,
|
||||
so on a slower connection those fetches exceeded Home Assistant's
|
||||
`SLOW_SETUP_MAX_WAIT`, the platform was cancelled, and the whole config entry
|
||||
landed in `setup_error` — every entity `unavailable` despite valid
|
||||
credentials. The listing now runs once in the background: the entity comes up
|
||||
immediately with the current box as its option and the rest fill in when the
|
||||
listing returns. Regression covered in `tests/test_setup_hygiene.py`.
|
||||
- Every portal request now carries a 30-second timeout. The shared Home
|
||||
Assistant session otherwise inherits aiohttp's five-minute default, long
|
||||
enough for one hung request to drag a refresh — or a first setup — past Home
|
||||
Assistant's own limits and fail it outright. A slow or unreachable site now
|
||||
surfaces as a normal retry instead.
|
||||
|
||||
## [0.4.0] - 2026-08-03
|
||||
|
||||
Control, not just reporting: the box can now be managed from Home Assistant.
|
||||
|
||||
### Added
|
||||
|
||||
- Skip and restore, donate, add/remove add-ons, subscribe/unsubscribe,
|
||||
vacation holds, and produce-box switching — every reversible one verified by
|
||||
a live round trip that returned the account to its prior state.
|
||||
- Entities for each: a produce-box select, a skip switch, a donate button, an
|
||||
add-ons to-do list the built-in conversation agent can drive, and sensors for
|
||||
subscriptions and vacation holds.
|
||||
- A `freshharvest_action` event after every write, carrying action/success/
|
||||
target/detail so automations can notify on the outcome.
|
||||
- A four-section dashboard view with the controls in place, exported to
|
||||
`examples/dashboard-view.yaml`.
|
||||
|
||||
### Notes
|
||||
|
||||
- Donating is the one action never executed: it cannot be undone, so it will be
|
||||
proven the first time there is a box actually worth giving away.
|
||||
- Entities are not exposed to the conversation agent by default; that is the
|
||||
operator's call.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
@@ -18,6 +109,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
site, refreshing the matrix in README and opening an issue on drift.
|
||||
- `FreshHarvestClient.async_fetch`, restored as the shared authenticated-fetch
|
||||
primitive that both the snapshot read and every write action build on.
|
||||
- `todo.fresh_harvest_box`: the order as a to-do list, so Home Assistant's own
|
||||
conversation agent can add and remove items with no bespoke voice code.
|
||||
Adding resolves the name against the site's search index first.
|
||||
- `switch.fresh_harvest_skip_next_order` (a switch, not a button, so the state
|
||||
is readable and reversible) and `button.fresh_harvest_donate_next_order`
|
||||
(a button, because donating cannot be undone).
|
||||
- `sensor.fresh_harvest_subscriptions` and `sensor.fresh_harvest_vacation_holds`,
|
||||
each listing the detail in attributes.
|
||||
- A `freshharvest_action` event fired after every write action, carrying
|
||||
action/success/target/detail so automations can notify on the outcome.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Subscription parsing matched `.account-item-multi-fields`, which is the
|
||||
heading row, so an account with a live subscription reported zero. Cells are
|
||||
now picked by semantic class. Regression covered in `tests/test_actions.py`.
|
||||
|
||||
- Restore (un-skip) via `POST /s/submit/restore-delivery`, wired to
|
||||
`switch.turn_off`. The restore popup only exists once an order is actually
|
||||
skipped, which is why it could not be found until one was. Verified end to
|
||||
end against a live order: skipped Aug 18, restored it, confirmed the account
|
||||
returned to its previous state.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Subscription parsing matched `.account-item-multi-fields`, which is the
|
||||
heading row, so an account with a live subscription reported zero. Cells are
|
||||
now picked by semantic class. Regression covered in `tests/test_actions.py`.
|
||||
- `async_fetch` treated popup bodies and AJAX replies as full pages. Those are
|
||||
fragments with no navigation, so the signed-in heuristic read every one as
|
||||
logged out, re-authenticated pointlessly and then failed. They now pass
|
||||
`is_page=False`. This blocked skip entirely.
|
||||
- The to-do list mixed produce-box contents with add-ons. Only add-ons can be
|
||||
added and removed — the box is chosen, not assembled — so listing produce
|
||||
invited deletes with no endpoint behind them. The entity is now
|
||||
`todo.fresh_harvest_add_ons`; box contents stay read-only on
|
||||
`sensor.*_next_delivery_items`.
|
||||
|
||||
- Produce box switching: `POST /s/submit/select-basket`, exposed as
|
||||
`select.fresh_harvest_produce_box` with all ten boxes. Switching a box is not
|
||||
adding an add-on — you change which box arrives, not what is inside it — so
|
||||
it is a select, where add-ons are a to-do list.
|
||||
|
||||
### Known gaps
|
||||
|
||||
- Entities are deliberately NOT exposed to the conversation agent yet.
|
||||
|
||||
### Notes
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<p align="center">
|
||||
<img src="docs/logo.svg" alt="Fresh Harvest" width="480">
|
||||
</p>
|
||||
|
||||
# ha-freshharvest
|
||||
|
||||
[](https://git.arch.fyi/flan/ha-freshharvest/actions)
|
||||
|
||||
Unofficial Home Assistant integration for [Fresh Harvest](https://freshharvest.com/),
|
||||
the Georgia local-produce delivery subscription.
|
||||
|
||||
@@ -32,6 +38,26 @@ The next delivery and the *open* order are usually two different deliveries.
|
||||
Once an order passes its cutoff it locks for packing, and the cart you can still
|
||||
edit is the following week's — so both are exposed separately.
|
||||
|
||||
Three things arrive in a delivery and only one is a list you edit:
|
||||
|
||||
| | What it is | Entity |
|
||||
| --- | --- | --- |
|
||||
| **Produce box** | Chosen, not assembled. Ten options. | `select.fresh_harvest_produce_box` |
|
||||
| **Box contents** | Fresh Harvest fills it; read-only. | `produce` attr of `..._next_delivery_items` |
|
||||
| **Add-ons** | Yours to add and remove. | `todo.fresh_harvest_add_ons` |
|
||||
|
||||
### Controls
|
||||
|
||||
| Entity | Notes |
|
||||
| --- | --- |
|
||||
| `select.fresh_harvest_produce_box` | Switches the **next delivery only**, not the standing order |
|
||||
| `switch.fresh_harvest_skip_next_order` | Skip, and turn back off to restore |
|
||||
| `button.fresh_harvest_donate_next_order` | Donates the box. **Not reversible** |
|
||||
| `todo.fresh_harvest_add_ons` | Add/remove items; names resolve via the site's search index |
|
||||
|
||||
Home Assistant's built-in conversation agent can drive the to-do list through
|
||||
`HassListAddItem`, so no bespoke voice work is needed.
|
||||
|
||||
### The order arriving next
|
||||
|
||||
| Entity | Example |
|
||||
@@ -45,11 +71,6 @@ edit is the following week's — so both are exposed separately.
|
||||
| `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 |
|
||||
@@ -61,94 +82,21 @@ are optional or promotional rather than charges.
|
||||
| `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.
|
||||
when the cutoff passes, the last moment to change the box.
|
||||
|
||||
### The account
|
||||
|
||||
| Entity | Example |
|
||||
| --- | --- |
|
||||
| `sensor.fresh_harvest_delivery_day` | `Tuesdays` |
|
||||
| `sensor.fresh_harvest_subscriptions` | `1`, with each standing order in attributes |
|
||||
| `sensor.fresh_harvest_vacation_holds` | `0`, with ranges in attributes |
|
||||
|
||||
## Consistency guarantees
|
||||
## Events
|
||||
|
||||
Two invariants hold against the portal's own arithmetic, and tests assert both:
|
||||
|
||||
- `next_delivery_box_price` + `next_delivery_add_ons` == `next_delivery_subtotal`
|
||||
- `open_order_free_delivery_remaining` reaching `0.00` always coincides with a
|
||||
`0.00` delivery fee
|
||||
|
||||
## Upstream compatibility
|
||||
|
||||
freshharvest.com has no API and no stability contract — this integration reads
|
||||
HTML and posts to form endpoints, so a redesign can change what a value *means*
|
||||
without changing its shape. [tools/compat.py](tools/compat.py) records every
|
||||
assumption and CI asserts them against the live site daily, refreshing this
|
||||
table and opening an issue on drift.
|
||||
|
||||
<!-- COMPAT:START -->
|
||||
_Last checked 2026-08-03._
|
||||
|
||||
| Area | Assumption | Status | Detail |
|
||||
| --- | --- | --- | --- |
|
||||
| Login | `/s/popup/login` serves the form | ✅ | 2277 bytes |
|
||||
| Login | hidden `LoginSecurity` is minted | ✅ | 156 chars |
|
||||
| Login | hidden `SubmitToken` is minted | ✅ | 176 chars |
|
||||
| Login | posts to `/s/submit/login` | ✅ | /s/submit/login |
|
||||
| Login | field `LoginEmail` present | ✅ | |
|
||||
| Login | field `LoginPassword` present | ✅ | |
|
||||
| Catalogue | Algolia credentials readable from site JS | ✅ | app id + search key found |
|
||||
| Catalogue | index name readable | ✅ | dev_FullTest |
|
||||
| Catalogue | index returns a plausible catalogue | ✅ | 945 records |
|
||||
| Catalogue | record field `ID` | ✅ | present |
|
||||
| Catalogue | record field `Name` | ✅ | present |
|
||||
| Catalogue | record field `Price` | ✅ | present |
|
||||
| Catalogue | record field `Measurement` | ✅ | present |
|
||||
| Catalogue | record field `Categories` | ✅ | present |
|
||||
| Endpoints | cart add/remove URL shape unchanged | ✅ | /p/Ajax/order-manage/ |
|
||||
| Endpoints | popup route is `/x/popup/{type}/{token}` | ✅ | found |
|
||||
<!-- COMPAT:END -->
|
||||
|
||||
Only the unauthenticated surface is checked here. The authenticated contract —
|
||||
dashboard markup, cart add hashes, skip popups, subscribe forms — needs a real
|
||||
session, and the only way to give public CI one is to put a personal grocery
|
||||
account's password in repo secrets. That belongs in a job on a host that already
|
||||
has credential access, not here.
|
||||
|
||||
## How it works
|
||||
|
||||
Fresh Harvest is not on Shopify, Farmigo, or Local Line — the page metadata
|
||||
reports `Vy Technology - Custom Code`. It is a server-rendered jQuery site with
|
||||
no JSON API and no mobile app, so this integration signs in and parses HTML.
|
||||
|
||||
Login is a two-step handshake:
|
||||
|
||||
1. `GET /s/popup/login` returns the form plus two hidden anti-replay fields,
|
||||
`LoginSecurity` and `SubmitToken`, minted per session.
|
||||
2. `POST /s/submit/login` with `LoginEmail`, `LoginPassword`, both tokens, and
|
||||
an empty `Redirect`, yielding an `fh_session_authenticated` cookie.
|
||||
|
||||
The tokens are bound to the cookie issued by step 1, so both requests must
|
||||
share a cookie jar. 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. One request per refresh,
|
||||
every six hours.
|
||||
|
||||
## Markup notes
|
||||
|
||||
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
|
||||
control, not by a status code.
|
||||
- **`cart-contents-skipped` does not mean the order was skipped.** It marks the
|
||||
locked cart — the one past its cutoff and arriving next. Treating it as
|
||||
"skipped" reports the wrong delivery as cancelled. The reliable signal for
|
||||
"can still be changed" is a non-empty `.cart-customize-wrapper`.
|
||||
- **The free-delivery bar only renders on carts below the threshold.** An order
|
||||
that already qualifies has no bar at all, so the threshold is read once from
|
||||
whichever cart shows it and applied to every order.
|
||||
Every write action fires `freshharvest_action` with `action`, `success`,
|
||||
`target` and `detail`, so an automation can notify on an add succeeding or a
|
||||
skip failing.
|
||||
|
||||
## Dashboard
|
||||
|
||||
@@ -157,23 +105,64 @@ Three traps, none guessable from the outside:
|
||||
full box contents rendered from the attributes, and the still-changeable order.
|
||||
Paste it under `views:` in the raw configuration editor.
|
||||
|
||||
## Tests
|
||||
## Requirements
|
||||
|
||||
Home Assistant 2025.2 or newer. Developed and running against 2026.7. The
|
||||
bundled brand images (the logo in Settings → Devices & Services) need 2026.3
|
||||
or newer; older versions simply keep the generic placeholder.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Everything shows `unavailable`.** The session expired and could not be
|
||||
renewed — usually a changed password. Reload the integration, or remove and
|
||||
re-add it.
|
||||
|
||||
**A count reads `0` when you know it should not.** Fresh Harvest changed their
|
||||
site. That is drift, not your configuration; please open an issue.
|
||||
|
||||
**Adding an item fails.** The item is not orderable for the open delivery. The
|
||||
site only offers an add control for things it can actually deliver, so this is
|
||||
the same answer you would get on the website.
|
||||
|
||||
**The box shows the wrong contents.** Contents are assigned a few days before
|
||||
delivery; an order that has not been filled yet legitimately has none.
|
||||
|
||||
## Contributing
|
||||
|
||||
Implementation notes, the endpoints this uses, and the markup traps worth
|
||||
knowing are in [docs/internals.md](docs/internals.md).
|
||||
|
||||
```
|
||||
pip install beautifulsoup4 pytest
|
||||
pip install beautifulsoup4 pytest yarl
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
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.
|
||||
## Scheduled sign-in check
|
||||
|
||||
## Compatibility
|
||||
Every break so far has been behind the login, and every one was silent: a
|
||||
sensor reading a plausible `0` rather than going unavailable. So once a day
|
||||
[tools/compat_auth.py](tools/compat_auth.py) signs in to a real account,
|
||||
read-only, and checks the markup the sensors and controls are parsed from:
|
||||
the dashboard, subscriptions, vacation holds, and the popups behind skip,
|
||||
donate and add. When something has moved, the run fails and the maintainer
|
||||
gets a push naming the broken assumption and what it would break.
|
||||
|
||||
Requires Home Assistant 2025.2 or newer. Developed and running against 2026.7.
|
||||
It runs only on the maintainer's own forge, where the account credentials
|
||||
are, and is skipped on the GitHub mirror and on forks. Its log is public, so
|
||||
it prints a pass or fail per assumption and nothing about the account. To run
|
||||
it yourself, set the four secrets listed at the top of
|
||||
[.github/workflows/compat-auth.yml](.github/workflows/compat-auth.yml) and
|
||||
change the job's `if:`.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Unofficial and unaffiliated — not endorsed by or supported by Fresh Harvest.
|
||||
The Fresh Harvest name and logo belong to Fresh Harvest and are used here only
|
||||
to identify the service this integrates with.
|
||||
Please do not lower the six-hour poll interval: this is a small business's
|
||||
website, not an API.
|
||||
|
||||
## Support
|
||||
|
||||
If ha-freshharvest is useful to you, consider supporting development via
|
||||
[GitHub Sponsors](https://github.com/sponsors/sudolulo) or [Ko-fi](https://ko-fi.com/sudolulo).
|
||||
|
||||
@@ -10,7 +10,14 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from .api import FreshHarvestClient
|
||||
from .coordinator import FreshHarvestCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.TODO,
|
||||
]
|
||||
|
||||
type FreshHarvestConfigEntry = ConfigEntry[FreshHarvestCoordinator]
|
||||
|
||||
|
||||
@@ -25,11 +25,18 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
|
||||
import aiohttp
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from yarl import URL
|
||||
|
||||
from .api import BASE, USER_AGENT, FreshHarvestClient, FreshHarvestError
|
||||
from .api import (
|
||||
BASE,
|
||||
REQUEST_TIMEOUT,
|
||||
USER_AGENT,
|
||||
FreshHarvestClient,
|
||||
FreshHarvestError,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,9 +46,21 @@ DASHBOARD_PAUSE = "/p/dashboard/pause-deliveries"
|
||||
SHOP_ITEM = "/p/shop/item/{item_id}/x"
|
||||
|
||||
SUBMIT_SKIP = "/s/submit/pause-delivery"
|
||||
SUBMIT_RESTORE = "/s/submit/restore-delivery"
|
||||
SUBMIT_DONATE = "/s/submit/donate-basket"
|
||||
SUBMIT_SUBSCRIBE = "/s/submit/item-frequency"
|
||||
SUBMIT_HOLD = "/s/submit/pause-range-add"
|
||||
SUBMIT_BASKET = "/s/submit/select-basket"
|
||||
SUBMIT_HOLD_REMOVE = "/s/submit/pause-range-remove"
|
||||
BASKET_TYPES = "/p/shop/basket-types"
|
||||
BASKET_GROUPS = (
|
||||
"georgia-grown-baskets",
|
||||
"mixed-fruit-and-veggie-baskets",
|
||||
"fruit-basket",
|
||||
)
|
||||
# The two submit buttons set this before posting: "do" = this delivery only,
|
||||
# "so" = the standing order, i.e. every future box.
|
||||
SCOPE_ONCE, SCOPE_STANDING = "do", "so"
|
||||
AJAX_ORDER_MANAGE = "/p/Ajax/order-manage/{mode}/{hash}/-/false/{ts}"
|
||||
|
||||
# id='FrequencyID' but name='popup-toggle' — the id is a decoy, the POST field
|
||||
@@ -112,39 +131,66 @@ def _find_form(soup: BeautifulSoup, action: str):
|
||||
|
||||
|
||||
def parse_subscriptions(html: str) -> list[Subscription]:
|
||||
"""Read /p/dashboard/manage-subscriptions."""
|
||||
"""Read /p/dashboard/manage-subscriptions.
|
||||
|
||||
Cells are picked by their semantic class rather than column position:
|
||||
`.account-item-multi-fields` is the HEADING row, and matching on it
|
||||
silently yields zero subscriptions on an account that has some.
|
||||
"""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
account = soup.select_one(".account")
|
||||
if account is None:
|
||||
return []
|
||||
|
||||
def cell(row, *classes) -> str | None:
|
||||
for cls in classes:
|
||||
found = row.select_one(f".account-item-text.{cls}")
|
||||
if found is not None:
|
||||
text = found.get_text(" ", strip=True)
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
subs: list[Subscription] = []
|
||||
for row in account.select(".account-item-multi-fields"):
|
||||
cells = [c.get_text(" ", strip=True) for c in row.select(".account-item-text")]
|
||||
cells = [c for c in cells if c]
|
||||
if len(cells) < 2:
|
||||
for row in account.select(".account-item-container"):
|
||||
name = cell(row, "account-item-description")
|
||||
if not name:
|
||||
continue
|
||||
qty = cells[0]
|
||||
qty = cell(row, "account-item-history-qty")
|
||||
subs.append(
|
||||
Subscription(
|
||||
name=cells[1],
|
||||
quantity=int(qty) if qty.isdigit() else None,
|
||||
arriving=cells[2] if len(cells) > 2 else None,
|
||||
partner=cells[3] if len(cells) > 3 else None,
|
||||
frequency=cells[4] if len(cells) > 4 else None,
|
||||
name=name,
|
||||
quantity=int(qty) if (qty or "").isdigit() else None,
|
||||
arriving=cell(row, "account-item-history"),
|
||||
partner=cell(row, "account-item-history-vendor"),
|
||||
frequency=cell(row, "center"),
|
||||
)
|
||||
)
|
||||
return subs
|
||||
|
||||
|
||||
def parse_vacation_holds(html: str) -> list[VacationHold]:
|
||||
"""Read the scheduled pauses off /p/dashboard/pause-deliveries."""
|
||||
"""Read the scheduled pauses off /p/dashboard/pause-deliveries.
|
||||
|
||||
The page renders a hold as "Tuesday, Dec 1 - Monday, Dec 7" — day names and
|
||||
abbreviated months, never ISO. An earlier version looked for YYYY-MM-DD and
|
||||
so reported no holds on an account that had one, which is indistinguishable
|
||||
from having none.
|
||||
"""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
account = soup.select_one(".account")
|
||||
if account is None:
|
||||
return []
|
||||
text = re.sub(r"\s+", " ", account.get_text(" ", strip=True))
|
||||
holds: list[VacationHold] = []
|
||||
for row in soup.select(".account-item-multi-fields, .account-item-container"):
|
||||
text = row.get_text(" ", strip=True)
|
||||
found = re.findall(r"\d{4}-\d{2}-\d{2}", text)
|
||||
if len(found) >= 2:
|
||||
holds.append(VacationHold(start=found[0], end=found[1], raw=text))
|
||||
pattern = re.compile(
|
||||
r"[A-Z][a-z]+,\s*([A-Z][a-z]{2})\s+(\d{1,2})\s*-\s*"
|
||||
r"[A-Z][a-z]+,\s*([A-Z][a-z]{2})\s+(\d{1,2})"
|
||||
)
|
||||
for m in pattern.finditer(text):
|
||||
start = f"{m.group(1)} {m.group(2)}"
|
||||
end = f"{m.group(3)} {m.group(4)}"
|
||||
holds.append(VacationHold(start=start, end=end, raw=m.group(0)))
|
||||
return holds
|
||||
|
||||
|
||||
@@ -163,7 +209,116 @@ def _confirmation_date(text: str) -> date | None:
|
||||
return None
|
||||
|
||||
|
||||
class FreshHarvestActions:
|
||||
@dataclass
|
||||
class Basket:
|
||||
"""A produce box you can switch to."""
|
||||
|
||||
item_id: str
|
||||
name: str
|
||||
is_current: bool = False
|
||||
token: str = ""
|
||||
|
||||
|
||||
class BasketMixin:
|
||||
"""Produce-box switching. Mixed into FreshHarvestActions.
|
||||
|
||||
A box is not an add-on: you swap which box arrives, you do not add or
|
||||
remove the produce inside it. The switch also has a scope the add-on
|
||||
endpoints do not — one delivery, or every future one.
|
||||
"""
|
||||
|
||||
async def async_list_baskets(self) -> list[Basket]:
|
||||
"""Every box you could switch TO.
|
||||
|
||||
Each option's real name lives in its own popup rather than the grid
|
||||
(the grid calls them all "Georgia Box"), so this reads them there.
|
||||
|
||||
The box you are already on is deliberately absent: the site offers no
|
||||
"switch to this" control for it, so there is no popup and no name. Its
|
||||
id shows up as every popup's `ReplaceItemID`, which is what
|
||||
`current_basket_id` returns; its NAME comes from the subscription list.
|
||||
"""
|
||||
baskets: list[Basket] = []
|
||||
seen: set[str] = set()
|
||||
for group in BASKET_GROUPS:
|
||||
page = await self._client.async_fetch(f"{BASKET_TYPES}/{group}")
|
||||
tokens = dict.fromkeys(
|
||||
re.findall(r'openPopup\("select-basket",\s*"([^"]+)"', page)
|
||||
)
|
||||
for token in tokens:
|
||||
popup = await self._client.async_fetch(
|
||||
f"/x/popup/select-basket/{token}", is_page=False
|
||||
)
|
||||
soup = BeautifulSoup(popup, "html.parser")
|
||||
form = _find_form(soup, SUBMIT_BASKET)
|
||||
if form is None:
|
||||
continue # a catch-all page, not a real popup
|
||||
fields = _hidden_fields(form)
|
||||
item_id = fields.get("ItemID", "")
|
||||
if not item_id or item_id in seen:
|
||||
continue
|
||||
seen.add(item_id)
|
||||
heading = soup.select_one("h4, h5, h6")
|
||||
baskets.append(
|
||||
Basket(
|
||||
item_id=item_id,
|
||||
name=(heading.get_text(" ", strip=True) if heading else item_id),
|
||||
is_current=False, # see the docstring: never offered
|
||||
token=token,
|
||||
)
|
||||
)
|
||||
return baskets
|
||||
|
||||
async def async_current_basket_id(self) -> str | None:
|
||||
"""The id of the box currently subscribed, read off any switch popup."""
|
||||
for group in BASKET_GROUPS:
|
||||
page = await self._client.async_fetch(f"{BASKET_TYPES}/{group}")
|
||||
for token in dict.fromkeys(
|
||||
re.findall(r'openPopup\("select-basket",\s*"([^"]+)"', page)
|
||||
):
|
||||
popup = await self._client.async_fetch(
|
||||
f"/x/popup/select-basket/{token}", is_page=False
|
||||
)
|
||||
form = _find_form(BeautifulSoup(popup, "html.parser"), SUBMIT_BASKET)
|
||||
if form is not None:
|
||||
return _hidden_fields(form).get("ReplaceItemID")
|
||||
return None
|
||||
|
||||
async def async_change_basket(
|
||||
self, name_or_id: str, all_future: bool = False, dry_run: bool = True
|
||||
) -> ActionResult:
|
||||
"""Switch to a different produce box.
|
||||
|
||||
`all_future=False` changes only the next delivery; True changes the
|
||||
standing order. Defaulting to the one-off is deliberate — a mistaken
|
||||
permanent change is the more annoying of the two to undo.
|
||||
"""
|
||||
wanted = str(name_or_id).strip().lower()
|
||||
for basket in await self.async_list_baskets():
|
||||
if wanted not in (basket.item_id.lower(), basket.name.lower()):
|
||||
continue
|
||||
popup = await self._client.async_fetch(
|
||||
f"/x/popup/select-basket/{basket.token}", is_page=False
|
||||
)
|
||||
form = _find_form(BeautifulSoup(popup, "html.parser"), SUBMIT_BASKET)
|
||||
if form is None:
|
||||
raise FreshHarvestActionError("basket form disappeared mid-flight")
|
||||
payload = _hidden_fields(form)
|
||||
payload["popup-toggle"] = SCOPE_STANDING if all_future else SCOPE_ONCE
|
||||
scope = "all future orders" if all_future else "the next delivery only"
|
||||
result = ActionResult(
|
||||
action="change_basket", ok=True, target=basket.name,
|
||||
submitted=payload, dry_run=dry_run,
|
||||
detail=f"switch to {basket.name} for {scope}",
|
||||
)
|
||||
if not dry_run:
|
||||
await self._post(SUBMIT_BASKET, payload)
|
||||
return result
|
||||
|
||||
raise FreshHarvestActionError(f"no box matches {name_or_id!r}")
|
||||
|
||||
|
||||
class FreshHarvestActions(BasketMixin):
|
||||
"""Mutating operations, each re-deriving its tokens from a live page."""
|
||||
|
||||
def __init__(self, client: FreshHarvestClient) -> None:
|
||||
@@ -171,13 +326,17 @@ class FreshHarvestActions:
|
||||
|
||||
async def _post(self, path: str, payload: dict[str, str]) -> str:
|
||||
session = self._client._session # noqa: SLF001 — same package
|
||||
try:
|
||||
async with session.post(
|
||||
BASE.join(URL(path)),
|
||||
data=payload,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.text()
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
raise FreshHarvestError(f"post to {path} failed: {err}") from err
|
||||
|
||||
# ------------------------------------------------------------------ skip
|
||||
|
||||
@@ -191,12 +350,14 @@ class FreshHarvestActions:
|
||||
submit and this raises rather than inventing one.
|
||||
"""
|
||||
page = await self._client.async_fetch(DASHBOARD_ORDERS)
|
||||
tokens = re.findall(r'openPopup\("pause-delivery","([^"]+)"', page)
|
||||
tokens = re.findall(r'openPopup\("pause-delivery",\s*"([^"]+)"', page)
|
||||
if not tokens:
|
||||
raise FreshHarvestActionError("no skippable delivery on this account")
|
||||
|
||||
for token in dict.fromkeys(tokens):
|
||||
popup = await self._client.async_fetch(f"/x/popup/pause-delivery/{token}")
|
||||
popup = await self._client.async_fetch(
|
||||
f"/x/popup/pause-delivery/{token}", is_page=False
|
||||
)
|
||||
form = _find_form(BeautifulSoup(popup, "html.parser"), SUBMIT_SKIP)
|
||||
if form is None:
|
||||
# Several tokens on the page are for other popups and fall
|
||||
@@ -240,15 +401,54 @@ class FreshHarvestActions:
|
||||
"cutoff and locked for packing"
|
||||
)
|
||||
|
||||
async def async_restore(
|
||||
self, delivery_date: date, dry_run: bool = True
|
||||
) -> ActionResult:
|
||||
"""Un-skip a delivery.
|
||||
|
||||
The restore popup only exists once an order is actually skipped — it is
|
||||
not on the page beforehand — so this is the exact inverse of a skip and
|
||||
raises when there is nothing to restore.
|
||||
"""
|
||||
page = await self._client.async_fetch(DASHBOARD_ORDERS)
|
||||
tokens = re.findall(r'openPopup\("restore-delivery",\s*"([^"]+)"', page)
|
||||
if not tokens:
|
||||
raise FreshHarvestActionError("no skipped delivery to restore")
|
||||
|
||||
for token in dict.fromkeys(tokens):
|
||||
popup = await self._client.async_fetch(
|
||||
f"/x/popup/restore-delivery/{token}", is_page=False
|
||||
)
|
||||
soup = BeautifulSoup(popup, "html.parser")
|
||||
form = _find_form(soup, SUBMIT_RESTORE)
|
||||
if form is None:
|
||||
continue
|
||||
stated = _confirmation_date(soup.get_text(" ", strip=True))
|
||||
if stated is not None and stated != delivery_date:
|
||||
continue
|
||||
payload = _hidden_fields(form) | {"Continue": "Confirm"}
|
||||
result = ActionResult(
|
||||
action="restore", ok=True, target=delivery_date.isoformat(),
|
||||
submitted=payload, dry_run=dry_run,
|
||||
detail=f"restore {delivery_date}",
|
||||
)
|
||||
if not dry_run:
|
||||
await self._post(SUBMIT_RESTORE, payload)
|
||||
return result
|
||||
|
||||
raise FreshHarvestActionError(f"no restore token matched {delivery_date}")
|
||||
|
||||
# ---------------------------------------------------------------- donate
|
||||
|
||||
async def async_donate(self, dry_run: bool = True) -> ActionResult:
|
||||
"""Donate the upcoming box. One-way — there is no undo in the UI."""
|
||||
page = await self._client.async_fetch(DASHBOARD_ORDERS)
|
||||
m = re.search(r'openPopup\("donate-delivery","([^"]+)"', page)
|
||||
m = re.search(r'openPopup\("donate-delivery",\s*"([^"]+)"', page)
|
||||
if not m:
|
||||
raise FreshHarvestActionError("no donatable delivery")
|
||||
popup = await self._client.async_fetch(f"/x/popup/donate-delivery/{m.group(1)}")
|
||||
popup = await self._client.async_fetch(
|
||||
f"/x/popup/donate-delivery/{m.group(1)}", is_page=False
|
||||
)
|
||||
form = _find_form(BeautifulSoup(popup, "html.parser"), SUBMIT_DONATE)
|
||||
if form is None:
|
||||
raise FreshHarvestActionError("donate form not found")
|
||||
@@ -267,7 +467,7 @@ class FreshHarvestActions:
|
||||
return await self._client.async_fetch(SHOP_ITEM.format(item_id=item_id))
|
||||
|
||||
async def async_add_item(
|
||||
self, item_id: int | str, dry_run: bool = True
|
||||
self, item_id: int | str, dry_run: bool = True, name: str | None = None
|
||||
) -> ActionResult:
|
||||
"""Add one of an item to the open order.
|
||||
|
||||
@@ -275,14 +475,16 @@ class FreshHarvestActions:
|
||||
absence *is* the out-of-stock signal — no separate stock lookup can go
|
||||
stale behind our back.
|
||||
"""
|
||||
return await self._cart_action("add", item_id, dry_run)
|
||||
return await self._cart_action("add", item_id, dry_run, name)
|
||||
|
||||
async def async_remove_item(
|
||||
self, item_id: int | str, dry_run: bool = True
|
||||
self, item_id: int | str, dry_run: bool = True, name: str | None = None
|
||||
) -> ActionResult:
|
||||
return await self._cart_action("remove", item_id, dry_run)
|
||||
return await self._cart_action("remove", item_id, dry_run, name)
|
||||
|
||||
async def _cart_action(self, mode: str, item_id, dry_run: bool) -> ActionResult:
|
||||
async def _cart_action(
|
||||
self, mode: str, item_id, dry_run: bool, name: str | None = None
|
||||
) -> ActionResult:
|
||||
page = await self._item_page(item_id)
|
||||
m = re.search(r'orderManage\("%s","([^"]+)"' % mode, page)
|
||||
if not m:
|
||||
@@ -290,22 +492,23 @@ class FreshHarvestActions:
|
||||
f"item {item_id} cannot be {mode}ed right now — the page offers "
|
||||
"no control for it, which usually means it is out of stock"
|
||||
)
|
||||
name = BeautifulSoup(page, "html.parser").select_one(".item-name")
|
||||
# Do NOT scrape a name off this page: the first `.item-name` belongs to
|
||||
# whatever is in the mini-cart, not the item being acted on, so an add
|
||||
# would announce someone else's groceries. Callers know the real name.
|
||||
url = AJAX_ORDER_MANAGE.format(
|
||||
mode=mode, hash=m.group(1), ts=int(time.time() * 1000)
|
||||
)
|
||||
result = ActionResult(
|
||||
action=f"{mode}_item",
|
||||
ok=True,
|
||||
target=name.get_text(" ", strip=True) if name else str(item_id),
|
||||
target=name or str(item_id),
|
||||
submitted={"url": url},
|
||||
dry_run=dry_run,
|
||||
detail=f"{mode} item {item_id}",
|
||||
)
|
||||
if not dry_run:
|
||||
# A fragment, not a page: no Sign Out control to detect, so bypass
|
||||
# the signed-in check. The item fetch above already renewed the session.
|
||||
await self._client._get(url) # noqa: SLF001 — same package
|
||||
# A fragment, not a page — see async_fetch(is_page=...).
|
||||
await self._client.async_fetch(url, is_page=False)
|
||||
return result
|
||||
|
||||
# --------------------------------------------------------- subscriptions
|
||||
@@ -382,3 +585,30 @@ class FreshHarvestActions:
|
||||
|
||||
async def async_list_vacation_holds(self) -> list[VacationHold]:
|
||||
return parse_vacation_holds(await self._client.async_fetch(DASHBOARD_PAUSE))
|
||||
|
||||
async def async_remove_vacation_hold(self, dry_run: bool = True) -> ActionResult:
|
||||
"""Lift the first scheduled hold.
|
||||
|
||||
Its popup only exists while a hold does, so this raises when there is
|
||||
nothing to lift rather than posting into the void.
|
||||
"""
|
||||
page = await self._client.async_fetch(DASHBOARD_PAUSE)
|
||||
tok = re.search(r'openPopup\("pause-range-remove",\s*"([^"]+)"', page)
|
||||
if not tok:
|
||||
raise FreshHarvestActionError("no scheduled hold to remove")
|
||||
popup = await self._client.async_fetch(
|
||||
f"/x/popup/pause-range-remove/{tok.group(1)}", is_page=False
|
||||
)
|
||||
soup = BeautifulSoup(popup, "html.parser")
|
||||
form = _find_form(soup, SUBMIT_HOLD_REMOVE)
|
||||
if form is None:
|
||||
raise FreshHarvestActionError("hold-removal form not found")
|
||||
payload = _hidden_fields(form) | {"Continue": "Confirm"}
|
||||
result = ActionResult(
|
||||
action="remove_vacation_hold", ok=True,
|
||||
target=re.sub(r"\s+", " ", soup.get_text(" ", strip=True))[:80],
|
||||
submitted=payload, dry_run=dry_run, detail="lift the scheduled hold",
|
||||
)
|
||||
if not dry_run:
|
||||
await self._post(SUBMIT_HOLD_REMOVE, payload)
|
||||
return result
|
||||
|
||||
@@ -33,6 +33,14 @@ USER_AGENT = (
|
||||
"Chrome/126.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# Bound every portal request. The shared Home Assistant session otherwise
|
||||
# inherits aiohttp's five-minute default, long enough for one hung request to
|
||||
# drag a refresh — or, during setup, an entire config entry — past Home
|
||||
# Assistant's own timeouts and into a hard failure. Thirty seconds sits far
|
||||
# above the portal's normal response yet still fails fast when it is
|
||||
# unreachable, so a slow site becomes a retry rather than a broken entry.
|
||||
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=30, connect=10)
|
||||
|
||||
# Hidden anti-replay fields, minted per session on each GET of the login form.
|
||||
_HIDDEN_RE = re.compile(
|
||||
r"name='(?P<name>LoginSecurity|SubmitToken)'[^>]*value='(?P<value>[^']*)'"
|
||||
@@ -135,6 +143,10 @@ class AccountSnapshot:
|
||||
# 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)
|
||||
# Populated by the coordinator from separate pages. Typed loosely because
|
||||
# actions.py imports this module, so it cannot be imported back from here.
|
||||
subscriptions: list = field(default_factory=list)
|
||||
vacation_holds: list = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def next_order(self) -> DeliveryOrder | None:
|
||||
@@ -311,11 +323,16 @@ class FreshHarvestClient:
|
||||
self._authenticated = False
|
||||
|
||||
async def _get(self, path: str) -> str:
|
||||
try:
|
||||
async with self._session.get(
|
||||
BASE.join(URL(path)), headers={"User-Agent": USER_AGENT}
|
||||
BASE.join(URL(path)),
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.text()
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
raise FreshHarvestError(f"request for {path} failed: {err}") from err
|
||||
|
||||
async def async_login(self) -> None:
|
||||
"""Run the two-step handshake: fetch tokens, then post credentials.
|
||||
@@ -343,10 +360,11 @@ class FreshHarvestClient:
|
||||
BASE.join(URL(LOGIN_SUBMIT)),
|
||||
data=payload,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
body = await resp.text()
|
||||
except aiohttp.ClientError as err:
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
raise FreshHarvestError(f"login request failed: {err}") from err
|
||||
|
||||
if not self._signed_in(body):
|
||||
@@ -362,18 +380,23 @@ class FreshHarvestClient:
|
||||
"""
|
||||
return "sign out" in body.lower()
|
||||
|
||||
async def async_fetch(self, path: str) -> str:
|
||||
"""Fetch any portal page, re-authenticating once if the session lapsed.
|
||||
async def async_fetch(self, path: str, *, is_page: bool = True) -> str:
|
||||
"""Fetch from the portal, re-authenticating once if the session lapsed.
|
||||
|
||||
The shared primitive for reads and for the token-scraping that every
|
||||
write action in `actions.py` has to do first.
|
||||
|
||||
Set ``is_page=False`` for popup bodies and AJAX replies. Those are HTML
|
||||
*fragments* with no navigation, so they never contain a Sign Out control
|
||||
and the signed-in heuristic would read every one of them as logged out —
|
||||
re-authenticating pointlessly and then failing.
|
||||
"""
|
||||
if not self._authenticated:
|
||||
await self.async_login()
|
||||
|
||||
try:
|
||||
body = await self._get(path)
|
||||
if not self._signed_in(body):
|
||||
if is_page and not self._signed_in(body):
|
||||
self._authenticated = False
|
||||
await self.async_login()
|
||||
body = await self._get(path)
|
||||
|
||||
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 73 KiB |
@@ -0,0 +1,58 @@
|
||||
"""One-way delivery actions.
|
||||
|
||||
Donating is a button rather than a switch because it genuinely cannot be undone
|
||||
from the portal — there is no state to toggle back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import FreshHarvestConfigEntry
|
||||
from .api import FreshHarvestError
|
||||
from .coordinator import FreshHarvestCoordinator
|
||||
from .entity import FreshHarvestEntity
|
||||
|
||||
DESCRIPTION = ButtonEntityDescription(
|
||||
key="donate_open_order",
|
||||
translation_key="donate_open_order",
|
||||
icon="mdi:hand-heart",
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FreshHarvestConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the button platform."""
|
||||
async_add_entities([FreshHarvestDonate(entry.runtime_data, entry)])
|
||||
|
||||
|
||||
class FreshHarvestDonate(FreshHarvestEntity, ButtonEntity):
|
||||
"""Donate the upcoming box to Share the Harvest."""
|
||||
|
||||
entity_description = DESCRIPTION
|
||||
|
||||
def __init__(
|
||||
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
|
||||
) -> None:
|
||||
super().__init__(coordinator, entry, DESCRIPTION.key)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return super().available and self.coordinator.data.open_order is not None
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Donate. Not reversible."""
|
||||
try:
|
||||
result = await self.coordinator.actions.async_donate(dry_run=False)
|
||||
except FreshHarvestError as err:
|
||||
self.fire_action("donate", False, "open order", str(err))
|
||||
raise HomeAssistantError(f"could not donate: {err}") from err
|
||||
self.fire_action("donate", True, "open order", result.detail)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -10,3 +10,7 @@ DOMAIN = "freshharvest"
|
||||
# schedules change on the order of days, and the customization cutoff is the
|
||||
# only time-sensitive value.
|
||||
UPDATE_INTERVAL = timedelta(hours=6)
|
||||
|
||||
# Fired after every write action so automations can notify on success or
|
||||
# failure. Data: domain, entry_id, action, success, target, detail.
|
||||
EVENT_ACTION = "freshharvest_action"
|
||||
|
||||
@@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .actions import FreshHarvestActions
|
||||
from .api import (
|
||||
AccountSnapshot,
|
||||
FreshHarvestAuthError,
|
||||
@@ -34,10 +35,15 @@ class FreshHarvestCoordinator(DataUpdateCoordinator[AccountSnapshot]):
|
||||
config_entry=entry,
|
||||
)
|
||||
self.client = client
|
||||
self.actions = FreshHarvestActions(client)
|
||||
|
||||
async def _async_update_data(self) -> AccountSnapshot:
|
||||
try:
|
||||
return await self.client.async_get_snapshot()
|
||||
snapshot = await self.client.async_get_snapshot()
|
||||
# Two extra pages per refresh, so three requests every six hours.
|
||||
snapshot.subscriptions = await self.actions.async_list_subscriptions()
|
||||
snapshot.vacation_holds = await self.actions.async_list_vacation_holds()
|
||||
return snapshot
|
||||
except FreshHarvestAuthError as err:
|
||||
raise ConfigEntryAuthFailed(str(err)) from err
|
||||
except FreshHarvestError as err:
|
||||
|
||||
@@ -15,7 +15,7 @@ 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 .const import DOMAIN, EVENT_ACTION
|
||||
from .coordinator import FreshHarvestCoordinator
|
||||
|
||||
Scope = Literal["account", "next_order", "open_order"]
|
||||
@@ -54,3 +54,21 @@ class FreshHarvestEntity(CoordinatorEntity[FreshHarvestCoordinator]):
|
||||
def target(self, scope: Scope) -> AccountSnapshot | DeliveryOrder | None:
|
||||
"""Resolve this entity's scope against the latest snapshot."""
|
||||
return resolve_scope(self.coordinator.data, scope)
|
||||
|
||||
def fire_action(self, action: str, ok: bool, target: str, detail: str) -> None:
|
||||
"""Announce a write action's outcome so automations can notify on it.
|
||||
|
||||
Lives here because all four control platforms need it identically; four
|
||||
private copies drifted apart the moment one of them gained a field.
|
||||
"""
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_ACTION,
|
||||
{
|
||||
"domain": DOMAIN,
|
||||
"entry_id": self.coordinator.config_entry.entry_id,
|
||||
"action": action,
|
||||
"success": ok,
|
||||
"target": target,
|
||||
"detail": detail,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
{
|
||||
"domain": "freshharvest",
|
||||
"name": "Fresh Harvest",
|
||||
"codeowners": ["@sudolulo"],
|
||||
"codeowners": [
|
||||
"@sudolulo"
|
||||
],
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/sudolulo/ha-freshharvest",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/sudolulo/ha-freshharvest/issues",
|
||||
"requirements": ["beautifulsoup4>=4.12"],
|
||||
"version": "0.3.0"
|
||||
"requirements": [
|
||||
"beautifulsoup4>=4.12"
|
||||
],
|
||||
"version": "0.5.1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Produce box selection.
|
||||
|
||||
Switching box is not adding an add-on: you change WHICH box arrives, not what
|
||||
is inside it. A select fits that — one choice from a fixed set — where the
|
||||
to-do list fits add-ons.
|
||||
|
||||
Selecting here changes the next delivery only. Changing the standing order is a
|
||||
different, stickier operation and is left to the portal rather than being one
|
||||
mis-click away from every future box.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import FreshHarvestConfigEntry
|
||||
from .api import FreshHarvestError
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FreshHarvestCoordinator
|
||||
from .entity import FreshHarvestEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DESCRIPTION = SelectEntityDescription(
|
||||
key="produce_box",
|
||||
translation_key="produce_box",
|
||||
icon="mdi:package-variant-closed",
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FreshHarvestConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the select platform."""
|
||||
async_add_entities([FreshHarvestBoxSelect(entry.runtime_data, entry)])
|
||||
|
||||
|
||||
class FreshHarvestBoxSelect(FreshHarvestEntity, SelectEntity):
|
||||
"""Which produce box arrives next."""
|
||||
|
||||
entity_description = DESCRIPTION
|
||||
|
||||
def __init__(
|
||||
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
|
||||
) -> None:
|
||||
super().__init__(coordinator, entry, DESCRIPTION.key)
|
||||
self._options: list[str] = []
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""The box actually arriving in the changeable delivery.
|
||||
|
||||
NOT the subscription. A one-off switch changes the delivery while the
|
||||
standing order keeps naming the old box — verified live: after
|
||||
switching the next delivery to Medium, the subscription still read
|
||||
Small. Reporting the subscription here would show the wrong box for
|
||||
exactly the week someone had changed it.
|
||||
"""
|
||||
order = self.coordinator.data.open_order or self.coordinator.data.next_order
|
||||
if order is not None and order.box_name:
|
||||
return order.box_name
|
||||
subs = self.coordinator.data.subscriptions
|
||||
return subs[0].name if subs else None
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
"""Boxes on offer, plus whatever is current so the state is valid."""
|
||||
current = self.current_option
|
||||
opts = list(self._options)
|
||||
if current and current not in opts:
|
||||
opts.insert(0, current)
|
||||
return opts
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
# Listing the boxes on offer costs one fetch per box popup. Doing it
|
||||
# here inline blocked entity setup: on a slower connection those fetches
|
||||
# ran past Home Assistant's SLOW_SETUP_MAX_WAIT and the whole config
|
||||
# entry was cancelled into "setup_error", leaving every entity
|
||||
# unavailable despite valid credentials. It now runs once, in the
|
||||
# background — the entity comes up immediately with the current box as
|
||||
# its only option and the rest appear when the listing returns.
|
||||
self.coordinator.config_entry.async_create_background_task(
|
||||
self.hass, self._async_load_options(), f"{DOMAIN}_list_baskets"
|
||||
)
|
||||
|
||||
async def _async_load_options(self) -> None:
|
||||
"""Fetch the switchable boxes once and publish them as options."""
|
||||
try:
|
||||
baskets = await self.coordinator.actions.async_list_baskets()
|
||||
except FreshHarvestError as err:
|
||||
_LOGGER.warning("could not list produce boxes: %s", err)
|
||||
return
|
||||
self._options = [b.name for b in baskets]
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Switch the next delivery to this box."""
|
||||
if option == self.current_option:
|
||||
return
|
||||
try:
|
||||
result = await self.coordinator.actions.async_change_basket(
|
||||
option, all_future=False, dry_run=False
|
||||
)
|
||||
except FreshHarvestError as err:
|
||||
self.fire_action("change_basket", False, option, str(err))
|
||||
raise HomeAssistantError(f"could not switch to {option}: {err}") from err
|
||||
self.fire_action("change_basket", True, option, result.detail)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -93,6 +93,38 @@ SENSORS: tuple[FreshHarvestSensorDescription, ...] = (
|
||||
"free_delivery_threshold": s.free_delivery_threshold,
|
||||
},
|
||||
),
|
||||
FreshHarvestSensorDescription(
|
||||
key="subscriptions",
|
||||
translation_key="subscriptions",
|
||||
scope="account",
|
||||
icon="mdi:autorenew",
|
||||
native_unit_of_measurement="items",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda s: len(s.subscriptions),
|
||||
attrs_fn=lambda s: {
|
||||
"items": [
|
||||
" ".join(
|
||||
p for p in (str(sub.quantity or ""), sub.name,
|
||||
f"({sub.frequency})" if sub.frequency else "")
|
||||
if p
|
||||
)
|
||||
for sub in s.subscriptions
|
||||
],
|
||||
"partners": sorted({sub.partner for sub in s.subscriptions if sub.partner}),
|
||||
},
|
||||
),
|
||||
FreshHarvestSensorDescription(
|
||||
key="vacation_holds",
|
||||
translation_key="vacation_holds",
|
||||
scope="account",
|
||||
icon="mdi:airplane",
|
||||
native_unit_of_measurement="holds",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda s: len(s.vacation_holds),
|
||||
attrs_fn=lambda s: {
|
||||
"ranges": [f"{h.start} to {h.end}" for h in s.vacation_holds]
|
||||
},
|
||||
),
|
||||
FreshHarvestSensorDescription(
|
||||
key="delivery_day",
|
||||
translation_key="delivery_day",
|
||||
|
||||
@@ -57,12 +57,38 @@
|
||||
},
|
||||
"shop_window": {
|
||||
"name": "Shopping window"
|
||||
},
|
||||
"subscriptions": {
|
||||
"name": "Subscriptions"
|
||||
},
|
||||
"vacation_holds": {
|
||||
"name": "Vacation holds"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
"order_open": {
|
||||
"name": "Order open"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"skip_open_order": {
|
||||
"name": "Skip next order"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"donate_open_order": {
|
||||
"name": "Donate next order"
|
||||
}
|
||||
},
|
||||
"todo": {
|
||||
"add_ons": {
|
||||
"name": "Add-ons"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"produce_box": {
|
||||
"name": "Produce box"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Skip control for the upcoming delivery.
|
||||
|
||||
A switch rather than a button: skipped/not-skipped is state the conversation
|
||||
agent can read back and reverse, where a button would be fire-and-forget.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import FreshHarvestConfigEntry
|
||||
from .api import FreshHarvestError
|
||||
from .coordinator import FreshHarvestCoordinator
|
||||
from .entity import FreshHarvestEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DESCRIPTION = SwitchEntityDescription(
|
||||
key="skip_open_order",
|
||||
translation_key="skip_open_order",
|
||||
icon="mdi:calendar-remove",
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FreshHarvestConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the switch platform."""
|
||||
async_add_entities([FreshHarvestSkip(entry.runtime_data, entry)])
|
||||
|
||||
|
||||
class FreshHarvestSkip(FreshHarvestEntity, SwitchEntity):
|
||||
"""On means the next changeable delivery is skipped."""
|
||||
|
||||
entity_description = DESCRIPTION
|
||||
|
||||
def __init__(
|
||||
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
|
||||
) -> None:
|
||||
super().__init__(coordinator, entry, DESCRIPTION.key)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Only meaningful while there is an order that can still be changed."""
|
||||
return super().available and self.coordinator.data.open_order is not None
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""A skipped order stops offering a shopping window."""
|
||||
order = self.coordinator.data.open_order
|
||||
return order is not None and not order.is_open
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Skip the next changeable delivery."""
|
||||
order = self.coordinator.data.open_order
|
||||
if order is None or order.delivery_date is None:
|
||||
raise HomeAssistantError("no delivery is currently skippable")
|
||||
try:
|
||||
result = await self.coordinator.actions.async_skip(
|
||||
order.delivery_date, dry_run=False
|
||||
)
|
||||
except FreshHarvestError as err:
|
||||
self.fire_action("skip", False, str(order.delivery_date), str(err))
|
||||
raise HomeAssistantError(f"could not skip: {err}") from err
|
||||
self.fire_action("skip", True, str(order.delivery_date), result.detail)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Un-skip the delivery."""
|
||||
order = self.coordinator.data.open_order
|
||||
if order is None or order.delivery_date is None:
|
||||
raise HomeAssistantError("no delivery to restore")
|
||||
try:
|
||||
result = await self.coordinator.actions.async_restore(
|
||||
order.delivery_date, dry_run=False
|
||||
)
|
||||
except FreshHarvestError as err:
|
||||
self.fire_action("restore", False, str(order.delivery_date), str(err))
|
||||
raise HomeAssistantError(f"could not restore: {err}") from err
|
||||
self.fire_action("restore", True, str(order.delivery_date), result.detail)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""The order's ADD-ONS as a to-do list.
|
||||
|
||||
Two different things arrive in a delivery and only one of them is a list you
|
||||
can edit:
|
||||
|
||||
* The **produce box** — the Georgia Grown Small Box and its contents. Fresh
|
||||
Harvest fills it; you pick the box, not the carrots in it. Swapping it is
|
||||
"Change Basket" on the portal, and its contents are read-only here, exposed
|
||||
as the `produce` attribute of `sensor.*_next_delivery_items`.
|
||||
* The **add-ons** — everything you chose individually. These are genuinely
|
||||
addable and removable, one endpoint each way.
|
||||
|
||||
This entity is the add-ons, because those are the ones where "add X" and
|
||||
"remove X" mean something. Listing box produce here would invite a delete that
|
||||
has no endpoint behind it, and the failure would surface as a confusing error
|
||||
rather than "that is not a thing you can do".
|
||||
|
||||
It exists so Home Assistant's own conversation agent can manage the order with
|
||||
no bespoke voice work: the built-in assistant already knows how to add and
|
||||
remove to-do items, so "add bananas to my Fresh Harvest add-ons" routes through
|
||||
`HassListAddItem` and lands here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import urllib.parse
|
||||
|
||||
from homeassistant.components.todo import (
|
||||
TodoItem,
|
||||
TodoItemStatus,
|
||||
TodoListEntity,
|
||||
TodoListEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import FreshHarvestConfigEntry
|
||||
from .api import REQUEST_TIMEOUT, FreshHarvestError
|
||||
from .coordinator import FreshHarvestCoordinator
|
||||
from .entity import FreshHarvestEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ALGOLIA_JS = "/_home/JavaScript/search_algolia.js"
|
||||
_CREDS_RE = re.compile(r'algoliasearch\(\s*"([^"]+)"\s*,\s*"([^"]+)"', re.S)
|
||||
_INDEX_RE = re.compile(r'indexName:\s*"([^"]+)"')
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FreshHarvestConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the to-do platform."""
|
||||
async_add_entities([FreshHarvestBox(entry.runtime_data, entry)])
|
||||
|
||||
|
||||
class FreshHarvestBox(FreshHarvestEntity, TodoListEntity):
|
||||
"""Everything in the upcoming delivery, as a list."""
|
||||
|
||||
_attr_translation_key = "add_ons"
|
||||
_attr_supported_features = (
|
||||
TodoListEntityFeature.CREATE_TODO_ITEM
|
||||
| TodoListEntityFeature.DELETE_TODO_ITEM
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
|
||||
) -> None:
|
||||
super().__init__(coordinator, entry, "add_ons")
|
||||
|
||||
@property
|
||||
def todo_items(self) -> list[TodoItem] | None:
|
||||
"""The open order's add-ons — the part of a delivery you control.
|
||||
|
||||
Scoped to the *open* order rather than the next arriving one, because a
|
||||
delete has to act on the order actually being shown; the next delivery
|
||||
is usually already locked for packing.
|
||||
"""
|
||||
snapshot = self.coordinator.data
|
||||
order = snapshot.open_order
|
||||
if order is None:
|
||||
return None
|
||||
items: list[TodoItem] = []
|
||||
for item in order.addons:
|
||||
label = " ".join(
|
||||
p for p in (str(item.quantity or ""), item.name, item.unit) if p
|
||||
)
|
||||
if item.price is not None:
|
||||
label = f"{label} — ${item.price:,.2f}"
|
||||
items.append(
|
||||
TodoItem(
|
||||
summary=label,
|
||||
uid=item.name,
|
||||
# Not a checklist: nothing here is ever "done", an add-on
|
||||
# either is or is not in the delivery.
|
||||
status=TodoItemStatus.NEEDS_ACTION,
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
async def _algolia_lookup(self, query: str) -> tuple[str, str]:
|
||||
"""Resolve a spoken name to (item_id, item_name) via the site's index.
|
||||
|
||||
Credentials are read from the site's own JS rather than hardcoded, so a
|
||||
key rotation fixes itself and a third party's key never enters this repo.
|
||||
"""
|
||||
client = self.coordinator.client
|
||||
js = await client.async_fetch(ALGOLIA_JS)
|
||||
creds, index = _CREDS_RE.search(js), _INDEX_RE.search(js)
|
||||
if not (creds and index):
|
||||
raise HomeAssistantError(
|
||||
"could not read the catalogue configuration from the site"
|
||||
)
|
||||
app, key = creds.groups()
|
||||
payload = json.dumps(
|
||||
{"params": urllib.parse.urlencode({"query": query, "hitsPerPage": 5})}
|
||||
).encode()
|
||||
session = client._session # noqa: SLF001 — same package
|
||||
async with session.post(
|
||||
f"https://{app}-dsn.algolia.net/1/indexes/{index.group(1)}/query",
|
||||
data=payload,
|
||||
headers={
|
||||
"X-Algolia-API-Key": key,
|
||||
"X-Algolia-Application-Id": app,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
hits = (await resp.json()).get("hits") or []
|
||||
if not hits:
|
||||
raise HomeAssistantError(f"nothing in the catalogue matches {query!r}")
|
||||
return str(hits[0]["ID"]), str(hits[0].get("Name") or query)
|
||||
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
"""Add an item to the order.
|
||||
|
||||
The site only renders an add control for something orderable right now,
|
||||
so an out-of-stock item fails here rather than silently doing nothing.
|
||||
"""
|
||||
query = (item.summary or "").strip()
|
||||
if not query:
|
||||
raise HomeAssistantError("no item name given")
|
||||
|
||||
item_id, name = await self._algolia_lookup(query)
|
||||
try:
|
||||
result = await self.coordinator.actions.async_add_item(
|
||||
item_id, dry_run=False, name=name
|
||||
)
|
||||
except FreshHarvestError as err:
|
||||
self.fire_action("add_item", False, query, str(err))
|
||||
raise HomeAssistantError(f"could not add {name}: {err}") from err
|
||||
|
||||
self.fire_action("add_item", True, name, result.detail)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
||||
"""Take items back out of the order."""
|
||||
for uid in uids:
|
||||
item_id, name = await self._algolia_lookup(uid)
|
||||
try:
|
||||
await self.coordinator.actions.async_remove_item(
|
||||
item_id, dry_run=False, name=name
|
||||
)
|
||||
except FreshHarvestError as err:
|
||||
self.fire_action("remove_item", False, uid, str(err))
|
||||
raise HomeAssistantError(f"could not remove {name}: {err}") from err
|
||||
self.fire_action("remove_item", True, name, "removed")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -57,12 +57,38 @@
|
||||
},
|
||||
"shop_window": {
|
||||
"name": "Shopping window"
|
||||
},
|
||||
"subscriptions": {
|
||||
"name": "Subscriptions"
|
||||
},
|
||||
"vacation_holds": {
|
||||
"name": "Vacation holds"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
"order_open": {
|
||||
"name": "Order open"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"skip_open_order": {
|
||||
"name": "Skip next order"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"donate_open_order": {
|
||||
"name": "Donate next order"
|
||||
}
|
||||
},
|
||||
"todo": {
|
||||
"add_ons": {
|
||||
"name": "Add-ons"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"produce_box": {
|
||||
"name": "Produce box"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Internals
|
||||
|
||||
How this integration talks to freshharvest.com. Nothing here is needed to *use*
|
||||
it — see the [README](../README.md) for that. This is for anyone changing the
|
||||
code, or working out why it broke.
|
||||
|
||||
## How it works
|
||||
|
||||
Fresh Harvest is not on Shopify, Farmigo, or Local Line — the page metadata
|
||||
reports `Vy Technology - Custom Code`. It is a server-rendered jQuery site with
|
||||
no JSON API and no mobile app, so this integration signs in and parses HTML.
|
||||
|
||||
Login is a two-step handshake:
|
||||
|
||||
1. `GET /s/popup/login` returns the form plus two hidden anti-replay fields,
|
||||
`LoginSecurity` and `SubmitToken`, minted per session.
|
||||
2. `POST /s/submit/login` with `LoginEmail`, `LoginPassword`, both tokens, and
|
||||
an empty `Redirect`, yielding an `fh_session_authenticated` cookie.
|
||||
|
||||
The tokens are bound to the cookie issued by step 1, so both requests must
|
||||
share a cookie jar.
|
||||
|
||||
A refresh is three GETs: `/p/dashboard/details` for the delivery day, next
|
||||
arrival, both upcoming carts and their totals; `/p/dashboard/manage-subscriptions`
|
||||
for standing orders; and `/p/dashboard/pause-deliveries` for vacation holds.
|
||||
Three requests every six hours.
|
||||
|
||||
Write actions cost more, because nothing can be constructed offline — every
|
||||
mutating endpoint is guarded by rotating per-render tokens, so each action
|
||||
fetches the page that offers it, reads fresh tokens, checks they describe the
|
||||
intended target, and only then submits.
|
||||
|
||||
## Markup notes
|
||||
|
||||
Six traps, none guessable from the outside:
|
||||
|
||||
- **HTTP status means nothing.** Every `/p/*` path returns 200, including
|
||||
invented ones. Signed-in state is detected by the presence of a Sign Out
|
||||
control, not by a status code.
|
||||
- **`cart-contents-skipped` does not mean the order was skipped.** It marks the
|
||||
locked cart — the one past its cutoff and arriving next. Treating it as
|
||||
"skipped" reports the wrong delivery as cancelled. The reliable signal for
|
||||
"can still be changed" is a non-empty `.cart-customize-wrapper`.
|
||||
- **The free-delivery bar only renders on carts below the threshold.** An order
|
||||
that already qualifies has no bar at all, so the threshold is read once from
|
||||
whichever cart shows it and applied to every order.
|
||||
- **Popups and AJAX replies are fragments, not pages.** They carry no
|
||||
navigation, so a "am I still signed in?" check based on a Sign Out control
|
||||
reads every one of them as logged out. Skip could not run at all until these
|
||||
were fetched with that check disabled.
|
||||
- **`openPopup` is written both `("x","y")` and `("x", "y")`.** A regex
|
||||
requiring no space silently matches nothing on the pages that use the other
|
||||
form — which is every basket page.
|
||||
- **A `<select>`'s `id` is not its POST field.** The subscribe form's frequency
|
||||
control is `id='FrequencyID'` but `name='popup-toggle'`. Posting
|
||||
`FrequencyID` is accepted and does nothing.
|
||||
|
||||
## 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
|
||||
|
||||
## Drift detection
|
||||
|
||||
freshharvest.com has no API and no stability contract — this integration reads
|
||||
HTML and posts to form endpoints, so a redesign can change what a value *means*
|
||||
without changing its shape. [tools/compat.py](../tools/compat.py) records every
|
||||
assumption and CI asserts them against the live site daily, refreshing this
|
||||
table and opening an issue on drift.
|
||||
|
||||
That covers only what anyone can see. The markup behind the login, where every
|
||||
real break so far has been, is checked by
|
||||
[tools/compat_auth.py](../tools/compat_auth.py) from a daily workflow that runs
|
||||
only on the maintainer's forge; see the README.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<svg viewBox="0 0 326 32" fill="#00935B" xmlns="http://www.w3.org/2000/svg" id="logoMain">
|
||||
<g clip-path="url(#clip0_2472_982)">
|
||||
<path d="M218.189 19.2978C219.832 23.3807 221.291 27.4931 222.997 31.5686C222.975 31.6866 222.839 31.7234 222.678 31.716C222.021 31.6939 221.365 31.6718 220.712 31.646C220.646 31.646 220.58 31.6387 220.514 31.6387C218.691 31.635 216.872 31.6276 215.049 31.6239C214.969 31.6239 214.884 31.6239 214.804 31.6129C214.609 31.5907 214.496 31.506 214.419 31.2996C213.792 29.6893 213.157 28.0827 212.508 26.4834C211.888 24.9579 211.243 23.447 210.616 21.9252C210.374 21.3319 210.161 20.7276 209.915 20.0827H207.509C207.509 21.2545 207.506 22.441 207.509 23.6276C207.52 25.6211 207.531 27.6184 207.55 29.6119C207.553 30.0541 207.59 30.5 207.586 30.9422C207.586 31.2186 207.539 31.4913 207.509 31.8045C206.937 31.8229 206.417 31.8524 205.896 31.8524C204.088 31.8524 202.28 31.8413 200.472 31.8266C199.625 31.8192 199.716 31.8634 199.716 31.0785C199.716 28.4696 199.727 25.8607 199.735 23.2517C199.742 21.9178 199.764 20.5802 199.768 19.2462C199.768 18.325 199.742 17.4001 199.738 16.4788C199.731 15.1965 199.735 13.9141 199.724 12.6281C199.713 11.0288 199.683 9.42953 199.68 7.83395C199.68 6.40051 199.702 4.96707 199.713 3.53363C199.716 2.95509 199.72 2.38024 199.727 1.80171C199.727 1.66905 199.742 1.53639 199.76 1.40742C199.819 0.998392 199.837 0.954173 200.244 0.924694C200.835 0.887844 201.429 0.884159 202.019 0.887844C203.134 0.895214 204.249 0.921009 205.364 0.932063C206.758 0.946803 208.155 0.961543 209.548 0.968913C211.455 0.976283 213.363 0.968913 215.27 0.983653C216.648 0.994707 217.961 1.26002 219.172 1.98964C219.967 2.46868 220.672 3.05827 221.357 3.6626C222.036 4.26325 222.531 5.05551 222.861 5.89567C223.114 6.54422 223.286 7.24436 223.301 7.96292C223.33 9.24897 223.363 10.535 223.389 11.8247C223.407 12.7533 223.066 13.5935 222.758 14.4447C222.446 15.307 222.006 16.0956 221.427 16.7957C220.547 17.8533 219.538 18.7598 218.189 19.2941V19.2978ZM207.568 12.3812C207.836 12.3812 208.049 12.3812 208.258 12.3812C209.849 12.3591 211.444 12.3333 213.036 12.3075C213.887 12.2927 214.657 11.5852 214.833 10.7635C215.163 9.22317 213.993 7.75288 212.446 7.78973C211.184 7.81921 209.923 7.87449 208.661 7.92608C208.338 7.94082 208.016 7.98503 207.641 8.02188C207.616 9.47007 207.59 10.8998 207.564 12.3775L207.568 12.3812Z" />
|
||||
<path d="M160.937 14.7545C159.056 14.7987 157.233 14.8392 155.359 14.8834V31.3146C155.209 31.3293 155.062 31.3551 154.916 31.3588C153.152 31.3993 151.388 31.4362 149.624 31.4694C149.037 31.4804 148.446 31.4841 147.86 31.4767C147.453 31.473 147.306 31.3477 147.302 30.935C147.291 29.9217 147.31 28.9046 147.317 27.8913C147.321 27.3901 147.343 26.889 147.346 26.3878C147.368 23.9373 147.394 21.4905 147.409 19.0401C147.416 17.8756 147.394 16.7149 147.401 15.5504C147.416 13.9327 147.453 12.315 147.475 10.6974C147.482 10.2257 147.46 9.75401 147.456 9.27866C147.453 8.51956 147.449 7.76046 147.456 7.00137C147.467 5.11099 147.482 3.22062 147.497 1.33025C147.497 1.24549 147.493 1.16074 147.497 1.07599C147.519 0.689067 147.665 0.523245 148.054 0.515875C148.678 0.501135 149.297 0.515875 149.921 0.515875C151.182 0.508505 152.44 0.50482 153.702 0.479025C154.457 0.460601 154.641 0.589574 154.663 1.34867C154.692 2.37308 154.677 3.3975 154.699 4.42191C154.721 5.45001 154.765 6.4781 154.802 7.5062C154.802 7.53568 154.835 7.56516 154.835 7.56885H160.989C160.989 6.88713 160.996 6.26806 160.989 5.64899C160.97 4.14922 160.948 2.64945 160.93 1.15337C160.93 1.06862 160.923 0.983862 160.937 0.899108C160.985 0.589574 161.212 0.401642 161.528 0.405327C163.086 0.423751 164.649 0.445861 166.207 0.453231C166.878 0.456916 167.55 0.431121 168.221 0.420066C168.356 0.420066 168.492 0.409012 168.624 0.420066C169.039 0.449546 169.189 0.604313 169.196 1.01703C169.211 2.01196 169.233 3.00689 169.237 4.00183C169.244 5.68584 169.237 7.37354 169.237 9.05756C169.237 10.83 169.237 12.6025 169.251 14.3749C169.266 16.7001 169.284 19.0253 169.31 21.3505C169.317 22.1059 169.369 22.8613 169.383 23.6204C169.398 24.4643 169.383 25.3045 169.387 26.1483C169.391 27.3275 169.398 28.5067 169.409 29.6858C169.413 30.2091 169.438 30.7324 169.442 31.2556C169.442 31.5762 169.38 31.6536 169.064 31.6573C168.144 31.661 167.227 31.6499 166.306 31.6425C165.888 31.6425 165.474 31.6352 165.056 31.6278C163.93 31.6131 162.804 31.5983 161.678 31.5836C161.227 31.5762 161.036 31.403 161.033 30.9461C161.029 30.0507 161.044 29.1552 161.04 28.2598C161.04 27.1653 161.033 26.0672 161.025 24.9728C161.018 23.5357 161.018 22.0986 161 20.6651C160.989 19.7291 160.945 18.7932 160.93 17.8535C160.919 17.076 160.93 16.3021 160.926 15.5246C160.926 15.3072 160.926 15.0898 160.926 14.7618L160.937 14.7545Z"/>
|
||||
<path d="M233.21 0.541246C235 5.61172 236.687 11.4192 238.469 16.4675C238.509 16.4675 238.55 16.4675 238.59 16.4675C238.737 15.9443 238.883 15.4173 239.03 14.8941C239.5 13.2469 239.973 11.5997 240.438 9.94889C240.699 9.03134 240.937 8.11011 241.194 7.18887C241.509 6.06128 241.825 4.93369 242.147 3.80979C242.287 3.31969 242.716 1.90467 242.862 1.41458C242.892 1.31877 242.917 1.22296 242.95 1.13084C243.152 0.59652 243.204 0.58178 243.779 0.563356C245.206 0.522821 249.222 0.570725 249.83 0.57441C250.219 0.57441 250.318 0.670219 250.241 1.05345C250.094 1.76096 249.68 3.19809 249.508 3.89822C249.449 4.13775 249.372 4.3699 249.306 4.60573C248.961 5.89546 248.617 7.1815 248.275 8.47123C247.766 10.4132 247.263 12.3588 246.754 14.3045C246.442 15.4837 246.115 16.6555 245.8 17.8347C245.635 18.4537 245.492 19.0802 245.331 19.7029C245.111 20.5284 244.876 21.3501 244.659 22.1755C244.307 23.5131 243.97 24.8545 243.618 26.1921C243.369 27.1502 243.097 28.1009 242.848 29.0553C242.683 29.6928 242.54 30.334 242.382 30.9751C242.246 31.5131 242.173 31.5389 241.634 31.5279C240.343 31.5058 239.048 31.5021 237.754 31.5058C236.866 31.5058 235.979 31.5426 235.091 31.55C234.879 31.55 234.67 31.5058 234.424 31.48C234.285 30.9973 234.149 30.5256 234.017 30.0539C233.573 28.4657 233.133 26.8738 232.686 25.2856C232.407 24.3017 232.11 23.3215 231.835 22.3377C231.523 21.2137 231.233 20.0825 230.914 18.9586C230.592 17.8162 230.247 16.6813 229.917 15.5426C229.66 14.6582 229.414 13.7702 229.161 12.8858C228.71 11.305 228.256 9.72043 227.801 8.13959C227.441 6.88671 227.075 5.63752 226.723 4.38464C226.481 3.52973 226.029 2.05944 225.806 1.20085C225.67 0.681274 225.751 0.58178 226.297 0.592835C228.479 0.530191 228.479 0.497027 229.37 0.541246C231.237 0.541246 232.935 0.563356 233.21 0.544931V0.541246Z"/>
|
||||
<path d="M325.913 0.780807C325.913 1.62097 325.902 2.38007 325.913 3.13548C325.931 4.30361 325.972 5.47173 325.997 6.63986C326.001 6.82042 325.983 7.00098 325.979 7.18523C325.968 7.59057 325.95 7.60531 325.572 7.60531C324.666 7.60531 322.675 7.609 321.769 7.61637C320.607 7.62742 319.444 7.64216 318.281 7.66059C318.171 7.66059 318.061 7.69375 317.937 7.71218C317.922 7.80798 317.896 7.88905 317.896 7.96644C317.907 9.46989 317.929 10.9697 317.929 12.4731C317.929 13.9729 317.904 15.4763 317.9 16.9761C317.893 19.5371 317.896 22.0945 317.896 24.6555C317.896 26.4869 317.896 28.322 317.896 30.1534C317.896 30.2529 317.896 30.3524 317.896 30.4519C317.904 30.7025 317.768 30.8167 317.537 30.8241C317.141 30.8352 316.748 30.8352 316.352 30.8352C314.775 30.8462 313.195 30.861 311.618 30.872C311.405 30.872 311.192 30.8573 310.98 30.8425C310.635 30.8167 310.591 30.7725 310.587 30.4188C310.576 29.4459 310.573 28.4694 310.562 27.4966C310.543 25.6984 310.521 23.9001 310.514 22.1019C310.507 20.2041 310.529 18.3064 310.514 16.4123C310.507 15.012 310.459 13.6081 310.452 12.2078C310.444 11.0028 310.466 9.80154 310.474 8.59656C310.474 8.30177 310.474 8.00697 310.474 7.64953C307.665 7.5832 304.899 7.76745 302.075 7.67533C302.05 7.50582 301.998 7.33631 302.002 7.1668C302.02 5.94709 302.057 4.72369 302.075 3.50398C302.086 2.64907 302.075 1.79048 302.075 0.90241C302.523 0.858191 302.908 0.788177 303.297 0.784492C304.397 0.769753 305.497 0.784492 306.597 0.784492C307.338 0.784492 308.075 0.784492 308.816 0.784492C309.777 0.784492 310.734 0.777123 311.691 0.784492C312.359 0.791862 313.026 0.832397 313.697 0.828712C314.445 0.828712 315.194 0.791862 315.938 0.784492C316.462 0.780807 316.987 0.806602 317.508 0.802917C319.422 0.795547 322.426 0.784492 324.336 0.773438C324.828 0.773438 325.315 0.773438 325.895 0.773438L325.913 0.780807Z"/>
|
||||
<path d="M299.258 2.37654C299.045 2.95139 298.858 3.4378 298.686 3.92421C298.216 5.2471 297.754 6.57 297.281 7.89289C297.105 8.38667 296.9 8.88045 296.592 9.30422C296.093 8.53038 295.363 8.00712 294.538 7.72707C293.614 7.41385 292.517 7.01587 291.52 7.05272C290.702 7.0822 290.1 7.1817 289.323 7.4249C289.154 7.47649 288.989 7.56493 288.85 7.67179C288.531 7.915 288.428 8.42352 288.571 8.81044C288.711 9.19367 288.956 9.50321 289.29 9.71325C289.825 10.0523 290.383 10.3471 290.929 10.6713C291.571 11.0509 292.217 11.4231 292.847 11.821C293.379 12.16 293.922 12.4917 294.402 12.897C295.308 13.6672 296.163 14.5 296.859 15.4765C297.424 16.2688 298.022 17.0536 298.48 17.9086C298.869 18.6345 299.093 19.4489 299.36 20.2337C299.753 21.3908 299.68 22.5958 299.603 23.7787C299.562 24.4051 299.346 25.0242 299.155 25.6322C298.997 26.126 298.777 26.605 298.557 27.0767C297.996 28.2706 297.087 29.2582 295.95 29.9141C295.517 30.1647 295.092 30.4373 294.648 30.6621C293.83 31.0748 292.943 31.2849 292.044 31.4138C291.098 31.5465 290.137 31.5944 289.184 31.6386C288.755 31.6607 288.325 31.6534 287.9 31.6091C287.035 31.517 286.165 31.3954 285.318 31.1928C284.515 31.0011 283.719 30.7469 282.96 30.4226C281.713 29.8883 280.492 29.2913 279.315 28.6207C279.054 28.4733 278.64 28.2043 278.394 28.0274C277.98 27.7289 277.958 27.6626 278.149 27.2204C278.427 26.5755 278.706 25.9343 278.977 25.2895C279.359 24.3719 279.726 23.4507 280.118 22.5368C280.323 22.0578 280.554 21.5935 280.789 21.1292C280.954 20.8012 281.057 20.7717 281.354 20.9523C282.021 21.3577 282.674 21.7925 283.356 22.1683C284.236 22.6511 285.131 23.0933 286.121 23.3401C286.98 23.5539 287.834 23.716 288.718 23.6718C289.371 23.6423 290.001 23.5502 290.599 23.2407C291.465 22.7948 291.578 22.0209 291.131 21.2324C290.757 20.5691 290.229 20.0274 289.624 19.5778C288.373 18.6492 287.123 17.7022 285.788 16.9062C284.985 16.4309 284.222 15.8892 283.525 15.2664C282.656 14.4852 281.845 13.634 281.167 12.6759C280.672 11.9758 280.452 11.1577 280.268 10.3213C280.037 9.27843 280.107 8.23559 280.246 7.20749C280.441 5.7851 280.895 4.44747 281.856 3.32725C283.95 0.891506 287.731 0.0476558 290.801 -0.000248431C291.461 -0.0113032 292.121 0.106615 292.785 0.143464C293.474 0.180314 294.157 0.305601 294.817 0.497218C295.411 0.666725 296.005 0.847287 296.592 1.04259C296.889 1.14208 297.175 1.26 297.453 1.40371C298.04 1.70956 298.62 2.03015 299.254 2.37285L299.258 2.37654Z" />
|
||||
<path d="M260.585 7.91114V11.9351C261.839 11.983 263.061 11.8651 264.282 11.8724C265.492 11.8798 266.702 11.9314 267.979 11.9646C267.99 12.3404 268.004 12.7052 268.008 13.0701C268.023 14.0613 268.037 15.0489 268.045 16.0401C268.056 17.0314 268.059 18.0226 268.063 19.0102C268.063 19.2939 268.008 19.3492 267.66 19.3529C266.882 19.3639 266.105 19.3455 265.323 19.3566C264.209 19.375 263.09 19.4081 261.975 19.4303C261.509 19.4376 261.04 19.4303 260.508 19.4303C260.38 20.1783 260.442 20.9706 260.424 21.7518C260.406 22.5293 260.42 23.3105 260.42 24.1322C260.78 24.1322 261.176 24.1322 261.572 24.1322C262.195 24.1286 262.819 24.1175 263.442 24.1138C264.568 24.1064 265.694 24.0917 266.82 24.0917C268.283 24.0917 269.75 24.1101 271.213 24.1064C272.361 24.1064 273.513 24.077 274.66 24.0622C275.167 24.0549 275.24 24.0843 275.255 24.5855C275.284 25.3962 275.269 26.2105 275.255 27.0249C275.24 27.8393 275.343 30.4703 275.269 30.9678C275.196 31.48 275.145 31.5021 274.638 31.4874C273.733 31.4653 272.599 31.4247 271.694 31.4174C269.834 31.4026 267.971 31.4026 266.112 31.3952C264.847 31.3879 263.585 31.3695 262.32 31.3621C261.256 31.3547 260.189 31.3621 259.126 31.351C258.678 31.351 258.227 31.3142 257.78 31.3105C257.266 31.3105 256.753 31.3437 256.236 31.34C255.447 31.3363 254.655 31.3216 253.867 31.2921C253.713 31.2884 253.544 31.2294 253.412 31.1484C253.317 31.0894 253.218 30.9531 253.218 30.8499C253.21 29.6191 253.229 28.3883 253.229 27.1539C253.229 26.2621 253.199 25.3704 253.199 24.4786C253.199 23.2773 253.207 22.0724 253.207 20.8711C253.207 20.0935 253.207 19.316 253.207 18.5385C253.207 16.7476 253.21 14.9567 253.207 13.1622C253.199 11.7619 253.174 10.3616 253.166 8.96135C253.159 7.74163 253.159 6.52192 253.163 5.3022C253.163 3.92035 253.17 2.5385 253.174 1.15665C253.174 0.979773 253.199 0.80658 253.214 0.596539C253.555 0.55969 253.874 0.497046 254.189 0.497046C256.261 0.485991 258.333 0.485991 260.402 0.478621C262.291 0.474936 264.183 0.471251 266.072 0.456511C266.922 0.449141 267.773 0.412292 268.628 0.404922C270.373 0.390182 272.119 0.386497 273.865 0.382812C274.279 0.382812 274.404 0.474936 274.418 0.883964C274.448 1.74255 274.455 2.60115 274.451 3.46342C274.448 4.72735 274.426 5.99129 274.411 7.25522C274.407 7.60529 274.275 7.74163 273.912 7.74532C273.021 7.76006 272.13 7.76743 271.239 7.77111C269.684 7.78217 268.129 7.78585 266.574 7.79691C264.986 7.80428 263.398 7.81165 261.81 7.82639C261.421 7.83007 261.036 7.86692 260.582 7.89272L260.585 7.91114Z" />
|
||||
<path d="M47.261 18.9367C48.9039 23.0196 50.3635 27.132 52.0688 31.2075C52.0468 31.3255 51.9111 31.3623 51.7498 31.3549C51.0933 31.3328 50.4369 31.3107 49.7841 31.2849C49.7181 31.2849 49.6521 31.2775 49.5861 31.2775C47.7634 31.2739 45.9444 31.2665 44.1217 31.2628C44.0411 31.2628 43.9567 31.2628 43.876 31.2518C43.6817 31.2296 43.568 31.1449 43.491 30.9385C42.8638 29.3282 42.2294 27.7216 41.5803 26.1223C40.9605 24.5968 40.315 23.0859 39.6879 21.564C39.4459 20.9708 39.2332 20.3664 38.9875 19.7216H36.5817C36.5817 20.8934 36.578 22.0799 36.5817 23.2665C36.5927 25.26 36.6037 27.2573 36.622 29.2508C36.6257 29.693 36.6624 30.1389 36.6587 30.5811C36.6587 30.8575 36.611 31.1301 36.5817 31.4434C36.0096 31.4618 35.4888 31.4913 34.9681 31.4913C33.1601 31.4913 31.3521 31.4802 29.5441 31.4655C28.6969 31.4581 28.7886 31.5023 28.7886 30.7174C28.7886 28.1085 28.7996 25.4996 28.807 22.8906C28.8143 21.5567 28.8363 20.219 28.84 18.8851C28.84 17.9639 28.8143 17.0389 28.8106 16.1177C28.8033 14.8354 28.807 13.553 28.796 12.267C28.785 10.6677 28.7556 9.06842 28.752 7.47285C28.752 6.0394 28.774 4.60596 28.785 3.17621C28.7886 2.59767 28.7923 2.02282 28.7996 1.44429C28.7996 1.31163 28.8143 1.17897 28.8326 1.05C28.8913 0.64097 28.9096 0.596751 29.3167 0.567272C29.9072 0.530422 30.5013 0.526737 31.0917 0.530422C32.2066 0.537792 33.3214 0.563587 34.4363 0.574642C35.8299 0.589381 37.2272 0.604121 38.6207 0.611491C40.5278 0.618861 42.4348 0.611491 44.3418 0.626231C45.7207 0.637286 47.0336 0.902601 48.2438 1.63222C49.0396 2.11126 49.7437 2.70085 50.4295 3.30518C51.108 3.90583 51.6031 4.69809 51.9331 5.53825C52.1862 6.1868 52.3586 6.88694 52.3732 7.6055C52.4026 8.89155 52.4356 10.1776 52.4612 11.4673C52.4796 12.3959 52.1385 13.2361 51.8305 14.0873C51.5187 14.9496 51.0787 15.7382 50.4992 16.4383C49.6191 17.4959 48.6105 18.4024 47.261 18.9367ZM36.6404 12.0201C36.9081 12.0201 37.1208 12.0201 37.3298 12.0201C38.9215 11.9979 40.5167 11.9722 42.1084 11.9464C42.9592 11.9316 43.7293 11.2241 43.9054 10.4024C44.2354 8.86207 43.0655 7.39178 41.5179 7.42863C40.2564 7.45811 38.9948 7.51338 37.7332 7.56497C37.4105 7.57971 37.0878 7.62393 36.7137 7.66078C36.6881 9.10896 36.6624 10.5387 36.6367 12.0164L36.6404 12.0201Z" />
|
||||
<path d="M120.835 14.7397C118.957 14.7839 117.134 14.8244 115.26 14.8687V31.2851C115.11 31.2998 114.963 31.3256 114.817 31.3293C113.056 31.3698 111.292 31.4067 109.532 31.4398C108.945 31.4509 108.355 31.4546 107.768 31.4472C107.361 31.4435 107.218 31.3182 107.21 30.9055C107.199 29.8922 107.218 28.8788 107.225 27.8654C107.229 27.3643 107.251 26.8631 107.254 26.362C107.276 23.9152 107.302 21.4684 107.317 19.0216C107.324 17.8608 107.302 16.6964 107.309 15.5356C107.324 13.9179 107.361 12.3039 107.383 10.6863C107.39 10.2146 107.368 9.74291 107.364 9.27124C107.361 8.51214 107.357 7.75305 107.364 6.99763C107.375 5.11095 107.39 3.22057 107.405 1.33389C107.405 1.24913 107.401 1.16438 107.405 1.07962C107.423 0.692706 107.574 0.526884 107.959 0.519514C108.582 0.504774 109.202 0.519514 109.825 0.519514C111.083 0.512144 112.345 0.508459 113.603 0.482665C114.358 0.46424 114.541 0.593213 114.563 1.35231C114.593 2.37672 114.578 3.40114 114.6 4.42555C114.622 5.45365 114.666 6.47806 114.703 7.50616C114.703 7.53564 114.736 7.56512 114.736 7.5688H120.882C120.882 6.89077 120.89 6.2717 120.882 5.65263C120.864 4.15655 120.842 2.65678 120.824 1.16069C120.824 1.07594 120.816 0.991186 120.831 0.906433C120.879 0.596898 121.106 0.408966 121.421 0.412651C122.98 0.431076 124.539 0.453185 126.097 0.460555C126.768 0.46424 127.439 0.438445 128.111 0.427391C128.246 0.427391 128.378 0.416336 128.514 0.427391C128.928 0.45687 129.079 0.611637 129.086 1.02435C129.101 2.01928 129.123 3.01422 129.126 4.00915C129.134 5.69317 129.126 7.37718 129.126 9.0612C129.126 10.8337 129.126 12.6024 129.141 14.3749C129.156 16.6964 129.174 19.0216 129.2 21.3431C129.207 22.0985 129.258 22.8539 129.273 23.6093C129.288 24.4495 129.273 25.2934 129.277 26.1335C129.28 27.3127 129.288 28.4882 129.299 29.6674C129.302 30.1906 129.328 30.7139 129.332 31.2372C129.332 31.5577 129.273 31.6351 128.958 31.6388C128.041 31.6425 127.12 31.6314 126.204 31.6241C125.785 31.6241 125.371 31.6167 124.957 31.6093C123.831 31.5946 122.705 31.5799 121.579 31.5651C121.128 31.5577 120.937 31.3846 120.937 30.9276C120.934 30.0322 120.948 29.1404 120.945 28.245C120.945 27.1506 120.937 26.0561 120.93 24.958C120.923 23.5246 120.923 22.0875 120.904 20.654C120.893 19.718 120.849 18.7821 120.835 17.8461C120.824 17.0723 120.835 16.2947 120.831 15.5209C120.831 15.3035 120.831 15.0861 120.831 14.7581L120.835 14.7397Z" />
|
||||
<path d="M63.4036 7.91163V11.9356C64.6578 11.9835 65.8791 11.8656 67.1003 11.8729C68.3105 11.8803 69.5207 11.9319 70.7969 11.9651C70.8079 12.3409 70.8226 12.7057 70.8263 13.0705C70.841 14.0618 70.8556 15.0494 70.863 16.0406C70.874 17.0318 70.8776 18.0231 70.8813 19.0107C70.8813 19.2944 70.8263 19.3497 70.4779 19.3534C69.7004 19.3644 68.9229 19.346 68.1418 19.357C67.0269 19.3755 65.9084 19.4086 64.7935 19.4307C64.3278 19.4381 63.8583 19.4307 63.3266 19.4307C63.1982 20.1788 63.2606 20.971 63.2422 21.7523C63.2239 22.5298 63.2386 23.311 63.2386 24.1327C63.598 24.1327 63.994 24.1327 64.3901 24.1327C65.0136 24.129 65.637 24.118 66.2605 24.1143C67.3863 24.1069 68.5122 24.0922 69.6381 24.0922C71.1013 24.0922 72.5683 24.1106 74.0315 24.1069C75.1794 24.1069 76.3309 24.0775 77.4788 24.0627C77.9849 24.0553 78.0583 24.0848 78.0729 24.586C78.1023 25.3967 78.0876 26.211 78.0729 27.0254C78.0583 27.8398 78.1609 30.4708 78.0876 30.9683C78.0142 31.4805 77.9629 31.5026 77.4568 31.4879C76.551 31.4658 75.4178 31.4252 74.5119 31.4178C72.6526 31.4031 70.7896 31.4031 68.9303 31.3957C67.665 31.3884 66.4035 31.3699 65.1382 31.3626C64.0747 31.3552 63.0075 31.3626 61.944 31.3515C61.4966 31.3515 61.0455 31.3147 60.5981 31.311C60.0847 31.311 59.5712 31.3442 59.0541 31.3405C58.2657 31.3368 57.4735 31.322 56.6851 31.2926C56.531 31.2889 56.3623 31.2299 56.2303 31.1488C56.135 31.0899 56.0359 30.9535 56.0359 30.8504C56.0286 29.6196 56.0469 28.3888 56.0469 27.1544C56.0469 26.2626 56.0176 25.3709 56.0176 24.4791C56.0176 23.2778 56.0249 22.0728 56.0249 20.8716C56.0249 20.094 56.0249 19.3165 56.0249 18.539C56.0249 16.7481 56.0286 14.9572 56.0249 13.1627C56.0176 11.7624 55.9919 10.3621 55.9846 8.96184C55.9773 7.74212 55.9773 6.52241 55.9809 5.30269C55.9809 3.92084 55.9883 2.53899 55.9919 1.15714C55.9919 0.980261 56.0176 0.807069 56.0323 0.597027C56.3733 0.560178 56.6924 0.497534 57.0078 0.497534C59.0798 0.486479 61.1519 0.486479 63.2202 0.479109C65.1089 0.475424 67.0013 0.471739 68.8899 0.457C69.7408 0.44963 70.5916 0.41278 71.4461 0.40541C73.1917 0.390671 74.9374 0.386986 76.683 0.383301C77.0974 0.383301 77.2221 0.475424 77.2368 0.884452C77.2661 1.74304 77.2734 2.60163 77.2698 3.46391C77.2661 4.72784 77.2441 5.99178 77.2294 7.25571C77.2258 7.60578 77.0937 7.74212 76.7307 7.74581C75.8395 7.76055 74.9484 7.76792 74.0572 7.7716C72.5023 7.78266 70.9473 7.78634 69.3924 7.7974C67.8044 7.80477 66.2164 7.81214 64.6285 7.82688C64.2398 7.83056 63.8547 7.86741 63.3999 7.8932L63.4036 7.91163Z"/>
|
||||
<path d="M101.904 2.37654C101.692 2.95139 101.505 3.4378 101.332 3.92421C100.863 5.2471 100.401 6.57 99.9276 7.89289C99.7515 8.38667 99.5462 8.88045 99.2381 9.30422C98.7394 8.53038 98.0096 8.00712 97.1844 7.72707C96.2602 7.41385 95.1637 7.01587 94.1662 7.05272C93.3484 7.0822 92.7469 7.1817 91.9695 7.4249C91.8008 7.47649 91.6357 7.56493 91.4964 7.67179C91.1773 7.915 91.0746 8.42352 91.2177 8.81044C91.357 9.19367 91.6027 9.50321 91.9365 9.71325C92.4719 10.0523 93.0293 10.3471 93.5758 10.6713C94.2175 11.0509 94.863 11.4231 95.4938 11.821C96.0255 12.16 96.5683 12.4917 97.0487 12.897C97.9545 13.6672 98.809 14.5 99.5058 15.4765C100.071 16.2688 100.668 17.0536 101.127 17.9086C101.516 18.6345 101.739 19.4489 102.007 20.2337C102.399 21.3908 102.326 22.5958 102.249 23.7787C102.209 24.4051 101.992 25.0242 101.802 25.6322C101.644 26.126 101.424 26.605 101.204 27.0767C100.643 28.2706 99.7332 29.2582 98.5963 29.9141C98.1636 30.1647 97.7382 30.4373 97.2944 30.6621C96.4766 31.0748 95.5891 31.2849 94.6906 31.4138C93.7445 31.5465 92.7836 31.5944 91.8301 31.6386C91.401 31.6607 90.9719 31.6534 90.5465 31.6091C89.681 31.517 88.8119 31.3954 87.9647 31.1928C87.1616 31.0011 86.3658 30.7469 85.6066 30.4226C84.3598 29.8883 83.1385 29.2913 81.9613 28.6207C81.7009 28.4733 81.2865 28.2043 81.0408 28.0274C80.6264 27.7289 80.6044 27.6626 80.7951 27.2204C81.0738 26.5755 81.3525 25.9343 81.6239 25.2895C82.0053 24.3719 82.3721 23.4507 82.7645 22.5368C82.9698 22.0578 83.2009 21.5935 83.4356 21.1292C83.6006 20.8012 83.7033 20.7717 84.0004 20.9523C84.6678 21.3577 85.3206 21.7925 86.0027 22.1683C86.8829 22.6511 87.7777 23.0933 88.7679 23.3401C89.626 23.5539 90.4805 23.716 91.3644 23.6718C92.0171 23.6423 92.6479 23.5502 93.2457 23.2407C94.1112 22.7948 94.2249 22.0209 93.7775 21.2324C93.4034 20.5691 92.8753 20.0274 92.2702 19.5778C91.0196 18.6492 89.7691 17.7022 88.4342 16.9062C87.631 16.4309 86.8682 15.8892 86.1714 15.2664C85.3023 14.4852 84.4918 13.634 83.8133 12.6759C83.3182 11.9758 83.0982 11.1577 82.9148 10.3213C82.6838 9.27843 82.7535 8.23559 82.8928 7.20749C83.0872 5.7851 83.5419 4.44747 84.5028 3.32725C86.6005 0.891506 90.3815 0.0476558 93.4474 -0.000248431C94.1075 -0.0113032 94.7676 0.106615 95.4314 0.143464C96.1209 0.180314 96.803 0.305601 97.4631 0.497218C98.0572 0.666725 98.6513 0.847287 99.2381 1.04259C99.5352 1.14208 99.8212 1.26 100.1 1.40371C100.687 1.70956 101.266 2.03015 101.901 2.37285L101.904 2.37654Z" />
|
||||
<path d="M25.7887 0.622448C25.1762 0.50453 24.6078 0.522955 24.021 0.567174C23.1775 0.629818 22.3267 0.596654 21.4759 0.596654C20.0713 0.596654 18.6667 0.585599 17.2621 0.596654C16.4223 0.600339 15.5862 0.651928 14.7463 0.655613C14.0019 0.659298 13.2537 0.615078 12.5093 0.618763C11.8418 0.618763 11.1744 0.666668 10.5069 0.670353C9.18666 0.677722 7.87009 0.662983 6.54985 0.670353C5.9154 0.670353 5.27729 0.696147 4.64284 0.710887C4.36412 0.718257 4.13308 0.928298 4.08541 1.23046C4.05607 1.40734 4.0304 1.5879 4.02306 1.76478C3.99006 3.15769 3.94971 4.54691 3.93505 5.93981C3.92404 6.98634 3.94605 8.03654 3.96072 9.08307C3.96805 9.52894 3.96072 12.2447 3.95705 14.4336H0.762805L0 21.9288H3.94972C3.96072 24.4714 3.97905 27.9905 3.99739 28.6685C3.99739 28.7827 3.97905 28.897 3.97539 29.0149C3.96072 29.704 3.92771 30.3894 3.94605 31.0785C3.96072 31.6423 4.18809 31.856 4.67585 31.8523C5.29563 31.8449 5.91174 31.8376 6.53152 31.8265C8.32117 31.797 10.1145 31.7675 11.9042 31.7418C12.1462 31.7418 12.2709 31.6312 12.2709 31.3917C12.2709 31.1485 12.2709 30.9053 12.2709 30.6584C12.2746 29.2139 12.2085 24.4382 12.1719 22.3378C12.1719 22.2125 12.1829 22.0835 12.1939 21.9067C14.3026 21.9509 19.6239 21.7961 21.7876 21.844C21.7876 21.6229 21.7876 21.4976 21.7876 21.3687C21.7729 20.7164 21.7949 16.7883 21.8096 15.3954C21.8096 15.0895 21.8463 14.6731 21.8169 14.3488C19.2718 14.3967 14.2256 14.1904 12.0398 14.2014C12.0912 12.9043 11.9812 9.30416 12.1792 7.98864C13.0887 7.98864 13.9799 7.98864 14.8747 7.98864C16.657 7.98495 18.4357 7.97758 20.218 7.97021C20.7204 7.97021 21.2265 7.96284 21.7289 7.95547C22.2974 7.94442 22.8621 7.92231 23.4306 7.91494C24.065 7.90757 24.6995 7.92968 25.3339 7.91494C25.741 7.90389 25.818 7.80808 25.8217 7.40642C25.8253 6.94949 25.8217 6.49255 25.8217 6.03194C25.8253 5.11439 25.8327 4.19684 25.84 3.27929C25.8437 2.6897 25.8657 2.10379 25.862 1.5142C25.862 1.22309 25.818 0.931983 25.7923 0.626133L25.7887 0.622448Z" />
|
||||
<path d="M196.826 31.4917C196.775 31.2669 196.713 31.0458 196.65 30.8247C196.254 29.395 195.869 27.9652 195.462 26.5391C195.106 25.29 194.721 24.0444 194.358 22.7953C194.153 22.0841 193.969 21.3655 193.768 20.6543C193.287 18.9592 192.807 17.2642 192.323 15.5728C191.967 14.3236 191.6 13.0744 191.245 11.8252C190.812 10.307 190.383 8.78512 189.957 7.26324C189.657 6.18355 189.385 5.0965 189.07 4.02418C188.751 2.94449 188.578 1.81322 188.032 0.785118C187.977 0.774063 187.915 0.751953 187.852 0.751953C186.404 0.751953 184.959 0.759323 183.51 0.766693C182.773 0.766693 182.036 0.774063 181.299 0.766693C180.892 0.766693 180.598 0.910405 180.51 1.34891C180.496 1.42998 180.448 1.5 180.426 1.58106C180.147 2.61653 179.868 3.652 179.593 4.68747C179.348 5.62713 179.113 6.57047 178.864 7.50644C178.57 8.60087 178.251 9.69161 177.958 10.786C177.679 11.8362 177.43 12.8975 177.151 13.9477C176.627 15.9044 176.087 17.8574 175.559 19.8105C175.222 21.0486 174.885 22.2867 174.562 23.5322C174.169 25.0431 173.795 26.5649 173.399 28.0758C173.15 29.0302 172.879 29.9846 172.596 30.9279C172.49 31.2817 172.475 31.5986 172.699 31.9155C172.937 31.945 173.179 32.0002 173.425 31.9965C174.36 31.9892 175.292 31.956 176.227 31.9486C177.576 31.9339 178.922 31.9265 180.272 31.9302C180.532 31.9302 180.672 31.8491 180.727 31.5912C180.826 31.1048 180.936 30.6221 181.035 30.1393C181.277 28.9823 181.519 27.8289 181.761 26.6571H187.753C187.893 27.0882 188.01 27.4456 188.127 27.8031C188.248 28.1789 188.377 28.5548 188.487 28.9344C188.666 29.5497 188.817 30.1762 189.007 30.7879C189.319 31.7718 189.646 31.9634 190.658 31.9044C191.344 31.8676 192.033 31.8823 192.722 31.8897C193.874 31.9007 195.022 31.9228 196.173 31.9302C196.316 31.9302 196.463 31.886 196.606 31.8565C196.808 31.816 196.878 31.676 196.837 31.488L196.826 31.4917ZM186.228 19.1803C185.923 19.173 185.626 19.1914 185.318 19.1988C185.003 19.2098 184.684 19.2209 184.361 19.2209C184.038 19.2209 183.719 19.2098 183.396 19.1766L183.074 19.1435L183.14 18.8266C183.294 18.0638 183.499 17.2789 183.701 16.5235C183.899 15.7644 184.09 15.0458 184.229 14.3531L184.273 14.1283L185.036 14.0988L185.109 14.3088C185.52 15.5212 185.879 16.7077 186.261 17.9643L186.627 19.1766H186.228V19.1803Z" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2472_982">
|
||||
<rect width="326" height="32" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 26 KiB |
@@ -1,11 +1,16 @@
|
||||
# Fresh Harvest dashboard view.
|
||||
#
|
||||
# Four sections: the next delivery, its cost broken down per line, what is in
|
||||
# the box, and the order that can still be changed.
|
||||
# Four sections: the next delivery, what is in the box, the controls, and the
|
||||
# add-ons you can still edit.
|
||||
#
|
||||
# The split matters. The produce box is CHOSEN (a select), its contents are
|
||||
# read-only, and only add-ons are add/removable (a to-do list). The to-do list
|
||||
# edits the OPEN order while the totals describe the ARRIVING one, which is why
|
||||
# each is labelled with its date -- unlabelled, they read as a contradiction.
|
||||
#
|
||||
# To use it, open your dashboard, choose "Edit dashboard" -> "Raw configuration
|
||||
# editor", and paste this under `views:`. It needs the entities this
|
||||
# integration creates and nothing else — no custom cards.
|
||||
# editor", and paste this under `views:`. It needs only the entities this
|
||||
# integration creates -- no custom cards.
|
||||
|
||||
type: sections
|
||||
max_columns: 4
|
||||
@@ -44,37 +49,6 @@ sections:
|
||||
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_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) -%}
|
||||
@@ -112,24 +86,31 @@ sections:
|
||||
- type: grid
|
||||
cards:
|
||||
- type: heading
|
||||
heading: Still Open
|
||||
heading: Manage
|
||||
heading_style: title
|
||||
icon: mdi:cart-arrow-right
|
||||
icon: mdi:tune-variant
|
||||
- type: tile
|
||||
entity: binary_sensor.fresh_harvest_order_open
|
||||
name: Can still change
|
||||
icon: mdi:pencil-outline
|
||||
color: amber
|
||||
entity: select.fresh_harvest_produce_box
|
||||
name: Produce box
|
||||
icon: mdi:package-variant-closed
|
||||
color: green
|
||||
features:
|
||||
- type: select-options
|
||||
- type: tile
|
||||
entity: sensor.fresh_harvest_open_order_delivery
|
||||
name: Delivery
|
||||
icon: mdi:calendar-arrow-right
|
||||
entity: switch.fresh_harvest_skip_next_order
|
||||
name: Skip next order
|
||||
icon: mdi:calendar-remove
|
||||
color: amber
|
||||
- type: tile
|
||||
entity: sensor.fresh_harvest_shopping_window
|
||||
name: Shopping window
|
||||
icon: mdi:clock-alert-outline
|
||||
color: orange
|
||||
- type: tile
|
||||
entity: sensor.fresh_harvest_open_order_delivery
|
||||
name: Open order
|
||||
icon: mdi:calendar-arrow-right
|
||||
color: amber
|
||||
- type: tile
|
||||
entity: sensor.fresh_harvest_open_order_total
|
||||
name: Running total
|
||||
@@ -145,8 +126,57 @@ sections:
|
||||
{%- elif left > 0 -%}
|
||||
The **{{ when }}** order needs **${{ '%.2f' | format(left) }} more** for free delivery.
|
||||
{%- endif -%}
|
||||
- type: tile
|
||||
entity: button.fresh_harvest_donate_next_order
|
||||
name: Donate this box
|
||||
icon: mdi:hand-heart
|
||||
color: pink
|
||||
hide_state: true
|
||||
- type: markdown
|
||||
content: |-
|
||||
Once an order passes its cutoff it locks for packing, and the following week's cart opens.
|
||||
Donating gives the whole box away and **cannot be undone**.
|
||||
|
||||
[Change this order →](https://freshharvest.com/p/dashboard/manage-orders)
|
||||
[Manage on freshharvest.com →](https://freshharvest.com/p/dashboard/manage-orders)
|
||||
- type: grid
|
||||
cards:
|
||||
- type: heading
|
||||
heading: Add-ons & Standing Orders
|
||||
heading_style: title
|
||||
icon: mdi:cart-plus
|
||||
- type: markdown
|
||||
content: |-
|
||||
{%- set d = states('sensor.fresh_harvest_open_order_delivery') -%}
|
||||
{%- if d not in ['unknown', 'unavailable', 'none'] -%}
|
||||
Editing the **{{ (d | as_datetime).strftime('%b %-d') }}** order — the one still open.
|
||||
{%- else -%}
|
||||
No order is open for changes right now.
|
||||
{%- endif -%}
|
||||
- type: todo-list
|
||||
entity: todo.fresh_harvest_add_ons
|
||||
display_order: alpha_asc
|
||||
- type: tile
|
||||
entity: sensor.fresh_harvest_next_delivery_add_ons
|
||||
name: Arriving box add-ons
|
||||
icon: mdi:cart-plus
|
||||
color: purple
|
||||
- type: tile
|
||||
entity: sensor.fresh_harvest_subscriptions
|
||||
name: Subscriptions
|
||||
icon: mdi:autorenew
|
||||
color: cyan
|
||||
- type: markdown
|
||||
content: |-
|
||||
{%- set items = state_attr('sensor.fresh_harvest_subscriptions', 'items') or [] -%}
|
||||
{%- if items -%}
|
||||
**Repeating every week**
|
||||
{% for i in items %}
|
||||
- {{ i }}
|
||||
{%- endfor %}
|
||||
{%- else -%}
|
||||
_No standing orders beyond the box._
|
||||
{%- endif -%}
|
||||
- type: tile
|
||||
entity: sensor.fresh_harvest_vacation_holds
|
||||
name: Vacation holds
|
||||
icon: mdi:airplane
|
||||
color: blue
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for the subscription and vacation-hold parsers.
|
||||
|
||||
Written after a real bug: the first version matched `.account-item-multi-fields`,
|
||||
which is the HEADING row, so an account with a live subscription reported zero.
|
||||
A count of nothing looks exactly like an account with nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
COMPONENT = Path(__file__).parent.parent / "custom_components"
|
||||
|
||||
|
||||
def _load():
|
||||
if "aiohttp" not in sys.modules:
|
||||
stub = types.ModuleType("aiohttp")
|
||||
stub.ClientSession = object
|
||||
stub.ClientError = Exception
|
||||
stub.ClientTimeout = lambda *args, **kwargs: None
|
||||
sys.modules["aiohttp"] = stub
|
||||
sys.path.insert(0, str(COMPONENT))
|
||||
pkg = types.ModuleType("fh_pkg")
|
||||
pkg.__path__ = [str(COMPONENT / "freshharvest")]
|
||||
sys.modules["fh_pkg"] = pkg
|
||||
return importlib.import_module("fh_pkg.actions")
|
||||
|
||||
|
||||
actions = _load()
|
||||
|
||||
SUBS_HTML = """
|
||||
<div class='account'>
|
||||
<div class='account-item'>
|
||||
<div class='account-item-multi-fields'>
|
||||
<div class='account-item-heading account-item-history-qty'>Qty</div>
|
||||
<div class='account-item-heading account-item-description'>Item</div>
|
||||
<div class='account-item-heading account-item-history'>Arriving</div>
|
||||
<div class='account-item-heading account-item-history-vendor'>Partner</div>
|
||||
<div class='account-item-heading account-item-history center'>Frequency</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='account-item'>
|
||||
<div class='account-item-container'>
|
||||
<div class='account-item-text account-item-history-qty'><div class='center'>2</div></div>
|
||||
<div class='account-item-text account-item-description'>Georgia Grown Small Box</div>
|
||||
<div class='account-item-text account-item-history'>tomorrow</div>
|
||||
<div class='account-item-text account-item-history-vendor'>Various Partners</div>
|
||||
<div class='account-item-text account-item-history center'>Weekly</div>
|
||||
<div class='account-item-action account-item-history-action right'>Change Basket</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def test_heading_row_is_not_a_subscription():
|
||||
"""The regression: the header row must not be counted, and must not hide the real one."""
|
||||
subs = actions.parse_subscriptions(SUBS_HTML)
|
||||
assert len(subs) == 1
|
||||
assert subs[0].name == "Georgia Grown Small Box"
|
||||
|
||||
|
||||
def test_subscription_fields():
|
||||
sub = actions.parse_subscriptions(SUBS_HTML)[0]
|
||||
assert (sub.quantity, sub.frequency) == (2, "Weekly")
|
||||
assert sub.partner == "Various Partners"
|
||||
assert sub.arriving == "tomorrow"
|
||||
|
||||
|
||||
def test_no_subscriptions_is_empty_not_an_error():
|
||||
assert actions.parse_subscriptions("<html><body>nothing</body></html>") == []
|
||||
|
||||
|
||||
def test_vacation_hold_uses_the_sites_own_date_format():
|
||||
"""The page writes "Tuesday, Dec 1 - Monday, Dec 7", never ISO.
|
||||
|
||||
The first version of this test asserted ISO dates, so it passed against a
|
||||
format the site does not produce while the parser reported zero holds on an
|
||||
account that had one.
|
||||
"""
|
||||
html = (
|
||||
"<div class='account'><div class='account-item'>Upcoming Pauses"
|
||||
"<div class='account-item-container'>Tuesday, Dec 1 - Monday, Dec 7"
|
||||
"<a>Remove</a></div></div></div>"
|
||||
)
|
||||
holds = actions.parse_vacation_holds(html)
|
||||
assert len(holds) == 1
|
||||
assert (holds[0].start, holds[0].end) == ("Dec 1", "Dec 7")
|
||||
|
||||
|
||||
def test_no_holds_parses_empty():
|
||||
assert actions.parse_vacation_holds("<div class='account'>none</div>") == []
|
||||
|
||||
|
||||
def test_frequency_names_map_to_site_values():
|
||||
assert actions.FREQUENCIES["weekly"] == "1"
|
||||
# id='FrequencyID' but the POST field is popup-toggle; posting the id does nothing.
|
||||
assert actions.FREQUENCY_FIELD == "popup-toggle"
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Tests for tools/compat_auth.py, the signed-in drift check, with no network.
|
||||
|
||||
Its log is public, so besides the exit codes these pin down what it must never
|
||||
print: the credentials, and anything that reveals the account's state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
WORKFLOW = ROOT / ".github" / "workflows" / "compat-auth.yml"
|
||||
SCRIPT = ROOT / "tools" / "compat_auth.py"
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("compat_auth", SCRIPT)
|
||||
compat_auth = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(compat_auth)
|
||||
|
||||
EMAIL = "account-under-test"
|
||||
PASSWORD = "correct horse battery staple"
|
||||
ENV = {"FH_EMAIL": EMAIL, "FH_PASSWORD": PASSWORD}
|
||||
|
||||
HEALTHY = {
|
||||
"/s/popup/login": (
|
||||
"<input type='hidden' name='LoginSecurity' value='sec%3D%3D'>"
|
||||
"<input type='hidden' name='SubmitToken' value='tok'>"
|
||||
),
|
||||
"/p/dashboard/details": (
|
||||
"Your deliveries are Tuesdays. Next Arriving: Tue, Aug 4"
|
||||
"<div class='cart-contents' data-cart-select='1'>"
|
||||
"<span id='OrderTotals-123'>$66.16</span>"
|
||||
"<div class='cart-customize-wrapper'>Shop tomorrow</div>"
|
||||
"<progress value='38.42' max='50.00'></progress></div>"
|
||||
),
|
||||
"/p/dashboard/manage-subscriptions": (
|
||||
"<div class='account-item-container'>"
|
||||
"<span class='account-item-description'>Bananas</span>"
|
||||
"<span class='account-item-history-qty'>1</span></div>"
|
||||
),
|
||||
"/p/dashboard/pause-deliveries": (
|
||||
"<form action='/s/submit/pause-range-add'></form>"
|
||||
"<h3>Upcoming Pauses</h3><p>None scheduled</p></section>"
|
||||
),
|
||||
"/p/dashboard/manage-orders": (
|
||||
'openPopup("pause-delivery", "a") openPopup("donate-delivery", "b")'
|
||||
),
|
||||
"/p/shop/basket-types/georgia-grown-baskets": (
|
||||
"<div data-title='basket-12'>"
|
||||
"<a onclick='openPopup(\"select-basket\", \"c\")'>Choose</a></div>"
|
||||
),
|
||||
"/p/shop/item/6744/bananas": (
|
||||
'orderManage("add","d")'
|
||||
"<form action='/s/submit/item-frequency' class='popup-toggle'></form>"
|
||||
),
|
||||
}
|
||||
SIGNED_IN = "<a href='/logout'>Sign Out</a>"
|
||||
|
||||
|
||||
class FakePortal:
|
||||
"""Serves canned pages and records every form post."""
|
||||
|
||||
def __init__(self, pages=None, login_reply=SIGNED_IN, raise_on=None):
|
||||
self.pages = {**HEALTHY, **(pages or {})}
|
||||
self.login_reply = login_reply
|
||||
self.raise_on = raise_on or {}
|
||||
self.posts: list[tuple[str, dict]] = []
|
||||
|
||||
def get(self, path):
|
||||
if path in self.raise_on:
|
||||
raise self.raise_on[path]
|
||||
return self.pages[path]
|
||||
|
||||
def post(self, path, fields):
|
||||
self.posts.append((path, dict(fields)))
|
||||
return self.login_reply
|
||||
|
||||
|
||||
def run(capsys, portal=None, env=ENV):
|
||||
portal = portal or FakePortal()
|
||||
code = compat_auth.main(env=env, portal_factory=lambda: portal)
|
||||
out = capsys.readouterr().out
|
||||
assert EMAIL not in out and PASSWORD not in out
|
||||
return code, out, portal
|
||||
|
||||
|
||||
# --- environment -------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("env", "named"),
|
||||
[
|
||||
({}, ["FH_EMAIL", "FH_PASSWORD"]),
|
||||
({"FH_EMAIL": EMAIL}, ["FH_PASSWORD"]),
|
||||
({"FH_PASSWORD": PASSWORD}, ["FH_EMAIL"]),
|
||||
({"FH_EMAIL": " ", "FH_PASSWORD": "\n"}, ["FH_EMAIL", "FH_PASSWORD"]),
|
||||
],
|
||||
)
|
||||
def test_missing_credentials_exit_2_without_touching_the_network(capsys, env, named):
|
||||
def no_network():
|
||||
raise AssertionError("must not open a session without credentials")
|
||||
|
||||
code = compat_auth.main(env=env, portal_factory=no_network)
|
||||
out = capsys.readouterr().out
|
||||
assert code == compat_auth.MISCONFIGURED == 2
|
||||
for name in named:
|
||||
assert name in out
|
||||
assert EMAIL not in out and PASSWORD not in out
|
||||
|
||||
|
||||
def test_reads_the_process_environment_by_default(capsys, monkeypatch):
|
||||
monkeypatch.delenv("FH_EMAIL", raising=False)
|
||||
monkeypatch.delenv("FH_PASSWORD", raising=False)
|
||||
assert compat_auth.main() == 2
|
||||
|
||||
|
||||
def test_credentials_are_stripped():
|
||||
assert compat_auth.credentials(
|
||||
{"FH_EMAIL": f" {EMAIL}\n", "FH_PASSWORD": f"{PASSWORD}\n"}
|
||||
) == (EMAIL, PASSWORD)
|
||||
|
||||
|
||||
# --- exit-code mapping ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_all_seventeen_assumptions_hold_exit_5(capsys):
|
||||
code, out, portal = run(capsys)
|
||||
assert code == compat_auth.QUIET == 5
|
||||
assert "17/17 assumptions hold" in out
|
||||
assert "FAIL" not in out
|
||||
# The credentials went to the login POST, with the tokens unquoted.
|
||||
[(path, fields)] = portal.posts
|
||||
assert path == "/s/submit/login"
|
||||
assert fields["LoginEmail"] == EMAIL
|
||||
assert fields["LoginPassword"] == PASSWORD
|
||||
assert fields["LoginSecurity"] == "sec=="
|
||||
|
||||
|
||||
def test_drift_exit_10_names_the_assumption_and_symptom(capsys):
|
||||
portal = FakePortal(pages={"/p/dashboard/manage-subscriptions": "<table></table>"})
|
||||
code, out, _ = run(capsys, portal)
|
||||
assert code == compat_auth.FINDING == 10
|
||||
assert "[FAIL] subscription rows are .account-item-container" in out
|
||||
assert "reports 0 subscriptions" in out
|
||||
assert "15/17 assumptions hold" in out # rows and cells both went
|
||||
|
||||
|
||||
def test_rejected_sign_in_is_drift_and_still_prints_the_report(capsys):
|
||||
code, out, _ = run(capsys, FakePortal(login_reply="<p>Invalid login</p>"))
|
||||
assert code == 10
|
||||
assert "[FAIL] credentials accepted" in out
|
||||
assert "Sign-in failed" in out
|
||||
|
||||
|
||||
def test_login_form_without_tokens_is_drift(capsys):
|
||||
portal = FakePortal(pages={"/s/popup/login": "<form></form>"})
|
||||
code, out, _ = run(capsys, portal)
|
||||
assert code == 10
|
||||
assert "[FAIL] login form mints both anti-replay tokens" in out
|
||||
assert portal.posts == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
urllib.error.URLError("Name or service not known"),
|
||||
urllib.error.HTTPError(compat_auth.BASE, 503, "Service Unavailable", None, None),
|
||||
TimeoutError("timed out"),
|
||||
ConnectionResetError("reset by peer"),
|
||||
],
|
||||
)
|
||||
def test_unreachable_portal_exit_1(capsys, error):
|
||||
portal = FakePortal(raise_on={"/p/dashboard/details": error})
|
||||
code, out, _ = run(capsys, portal)
|
||||
assert code == compat_auth.CANNOT_RUN == 1
|
||||
assert "could not reach the portal" in out
|
||||
|
||||
|
||||
def test_network_error_text_is_redacted(capsys):
|
||||
error = OSError(f"refused for {EMAIL}")
|
||||
code, out, _ = run(capsys, FakePortal(raise_on={"/s/popup/login": error}))
|
||||
assert code == 1
|
||||
assert "***" in out
|
||||
|
||||
|
||||
def test_unexpected_error_exit_1_names_only_the_type(capsys):
|
||||
error = ValueError(f"state dump {EMAIL} {PASSWORD}")
|
||||
code, out, _ = run(capsys, FakePortal(raise_on={"/p/dashboard/details": error}))
|
||||
assert code == 1
|
||||
assert "ValueError" in out
|
||||
assert "state dump" not in out
|
||||
|
||||
|
||||
def test_exit_code_ignores_checks_with_nothing_to_assert():
|
||||
assert compat_auth.exit_code([("a", True, ""), ("b", compat_auth.SKIP, "")]) == 5
|
||||
assert compat_auth.exit_code([("a", True, ""), ("b", False, "x")]) == 10
|
||||
|
||||
|
||||
# --- what a public log may reveal ---------------------------------------------
|
||||
|
||||
|
||||
def test_a_scheduled_hold_is_indistinguishable_from_none(capsys):
|
||||
none = run(capsys)[1]
|
||||
hold = run(capsys, FakePortal(pages={"/p/dashboard/pause-deliveries": (
|
||||
"<form action='/s/submit/pause-range-add'></form>"
|
||||
"<h3>Upcoming Pauses</h3><p>Tuesday, Sep 8 - Tuesday, Sep 15</p></section>"
|
||||
)}))[1]
|
||||
assert none == hold
|
||||
assert "n/a" not in none and "not applicable" not in none
|
||||
|
||||
|
||||
def test_iso_hold_dates_are_drift(capsys):
|
||||
code, out, _ = run(capsys, FakePortal(pages={"/p/dashboard/pause-deliveries": (
|
||||
"<form action='/s/submit/pause-range-add'></form><h3>Upcoming Pauses</h3>"
|
||||
"<p>Tuesday, Sep 8 - Tuesday, Sep 15 (2026-09-08)</p></section>"
|
||||
)}))
|
||||
assert code == 10
|
||||
assert "[FAIL] hold ranges are day-name + abbreviated month, not ISO" in out
|
||||
|
||||
|
||||
def test_portal_posts_urlencoded_to_the_site():
|
||||
sent = []
|
||||
|
||||
class StubResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b"Sign Out"
|
||||
|
||||
class StubOpener:
|
||||
def open(self, req, timeout):
|
||||
sent.append((req, timeout))
|
||||
return StubResponse()
|
||||
|
||||
portal = compat_auth.Portal()
|
||||
portal._opener = StubOpener()
|
||||
assert portal.post("/s/submit/login", {"LoginEmail": EMAIL}) == "Sign Out"
|
||||
req, timeout = sent[0]
|
||||
assert req.full_url == "https://freshharvest.com/s/submit/login"
|
||||
assert urllib.parse.parse_qs(req.data.decode()) == {"LoginEmail": [EMAIL]}
|
||||
assert timeout == compat_auth.TIMEOUT
|
||||
|
||||
|
||||
# --- the workflow -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_workflow_lives_where_gitea_reads_it():
|
||||
# Gitea reads only the FIRST of .gitea/workflows and .github/workflows that
|
||||
# exists; creating .gitea/workflows would silently stop every workflow here.
|
||||
assert WORKFLOW.is_file()
|
||||
assert not (ROOT / ".gitea" / "workflows").exists()
|
||||
|
||||
|
||||
def test_workflow_shape():
|
||||
yaml = pytest.importorskip("yaml")
|
||||
doc = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
||||
triggers = doc.get("on", doc.get(True)) # YAML 1.1 reads a bare `on` as True
|
||||
assert set(triggers) == {"schedule", "workflow_dispatch"}
|
||||
assert triggers["schedule"] == [{"cron": "41 11 * * *"}]
|
||||
assert doc["permissions"] == {"contents": "read"}
|
||||
[job] = doc["jobs"].values()
|
||||
assert job["timeout-minutes"] == 10
|
||||
assert "github.server_url" in job["if"]
|
||||
|
||||
|
||||
def test_workflow_and_script_keep_private_details_out():
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
code = "\n".join(
|
||||
line for line in text.splitlines() if not line.lstrip().startswith("#")
|
||||
)
|
||||
assert "set -x" not in code and "xtrace" not in code
|
||||
assert set(re.findall(r"secrets\.(\w+)", text)) == {
|
||||
"FRESHHARVEST_EMAIL", "FRESHHARVEST_PASSWORD", "NTFY_URL", "NTFY_TOKEN",
|
||||
}
|
||||
for source in (text, SCRIPT.read_text(encoding="utf-8")):
|
||||
assert not re.search(r"[\w.+-]+@[\w-]+\.[\w.-]+", source), "an email address"
|
||||
assert not re.search(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", source), "an IP address"
|
||||
hosts = set(re.findall(r"https?://([^/\s'\"]+)", source))
|
||||
assert hosts <= {"freshharvest.com", "github.com"}, hosts
|
||||
@@ -26,6 +26,7 @@ def _load_api():
|
||||
stub = types.ModuleType("aiohttp")
|
||||
stub.ClientSession = object
|
||||
stub.ClientError = Exception
|
||||
stub.ClientTimeout = lambda *args, **kwargs: None
|
||||
sys.modules["aiohttp"] = stub
|
||||
if "yarl" not in sys.modules:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""No entity may block its own setup on a portal round-trip.
|
||||
|
||||
Written after a real bug: the produce-box select listed the boxes on offer from
|
||||
inside `async_added_to_hass`, one fetch per popup. On a slower connection those
|
||||
fetches ran past Home Assistant's SLOW_SETUP_MAX_WAIT, the platform setup was
|
||||
cancelled, and the whole config entry landed in `setup_error` — every entity
|
||||
unavailable despite valid credentials.
|
||||
|
||||
Every portal call an entity makes goes through `self.coordinator.actions` or
|
||||
`self.coordinator.client`, so awaiting either inside `async_added_to_hass` is
|
||||
the shape of that bug. These parse the sources with `ast` rather than importing
|
||||
them, so the suite still runs without Home Assistant installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
COMPONENT = Path(__file__).parent.parent / "custom_components" / "freshharvest"
|
||||
# Attribute names that only exist to reach the freshharvest.com portal.
|
||||
NETWORK_ATTRS = {"actions", "client"}
|
||||
|
||||
|
||||
def _attr_chain(node: ast.AST) -> set[str]:
|
||||
"""Every attribute/name along a call's dotted func, e.g. a.b.c() -> {a,b,c}."""
|
||||
names: set[str] = set()
|
||||
while isinstance(node, ast.Attribute):
|
||||
names.add(node.attr)
|
||||
node = node.value
|
||||
if isinstance(node, ast.Name):
|
||||
names.add(node.id)
|
||||
return names
|
||||
|
||||
|
||||
def _added_to_hass(filename: str) -> ast.AsyncFunctionDef | None:
|
||||
tree = ast.parse((COMPONENT / filename).read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.AsyncFunctionDef)
|
||||
and node.name == "async_added_to_hass"
|
||||
):
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
PLATFORMS = ["sensor.py", "binary_sensor.py", "switch.py", "button.py",
|
||||
"select.py", "todo.py"]
|
||||
|
||||
|
||||
def test_no_platform_awaits_the_portal_during_setup():
|
||||
"""`async_added_to_hass` must not await a coordinator.actions/client call."""
|
||||
offenders = []
|
||||
for filename in PLATFORMS:
|
||||
func = _added_to_hass(filename)
|
||||
if func is None:
|
||||
continue
|
||||
for node in ast.walk(func):
|
||||
if isinstance(node, ast.Await) and isinstance(node.value, ast.Call):
|
||||
if _attr_chain(node.value.func) & NETWORK_ATTRS:
|
||||
offenders.append(f"{filename}:{node.lineno}")
|
||||
assert not offenders, (
|
||||
"portal round-trip awaited during entity setup (blocks setup, can "
|
||||
f"exceed SLOW_SETUP_MAX_WAIT): {offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_select_still_loads_its_options_off_the_setup_path():
|
||||
"""The fix must defer the listing, not delete it: the select still fetches
|
||||
its options, just in a background task rather than inline in setup."""
|
||||
source = (COMPONENT / "select.py").read_text(encoding="utf-8")
|
||||
assert "async_create_background_task" in source
|
||||
assert "async_list_baskets" in source
|
||||
@@ -13,7 +13,13 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
COMPONENT = Path(__file__).parent.parent / "custom_components" / "freshharvest"
|
||||
PLATFORMS = {"sensor": "sensor.py", "binary_sensor": "binary_sensor.py"}
|
||||
PLATFORMS = {
|
||||
"sensor": "sensor.py",
|
||||
"binary_sensor": "binary_sensor.py",
|
||||
"switch": "switch.py",
|
||||
"button": "button.py",
|
||||
"select": "select.py",
|
||||
}
|
||||
|
||||
|
||||
def declared_keys(filename: str) -> set[str]:
|
||||
@@ -29,6 +35,22 @@ def declared_keys(filename: str) -> set[str]:
|
||||
}
|
||||
|
||||
|
||||
def attr_keys(filename: str) -> set[str]:
|
||||
"""Collect `_attr_translation_key = "..."` assignments (entities without a
|
||||
description object declare their name this way)."""
|
||||
tree = ast.parse((COMPONENT / filename).read_text(encoding="utf-8"))
|
||||
return {
|
||||
node.value.value
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Assign)
|
||||
and any(
|
||||
getattr(t, "id", getattr(t, "attr", None)) == "_attr_translation_key"
|
||||
for t in node.targets
|
||||
)
|
||||
and isinstance(node.value, ast.Constant)
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="strings")
|
||||
def strings_fixture() -> dict:
|
||||
return json.loads((COMPONENT / "strings.json").read_text(encoding="utf-8"))
|
||||
@@ -64,3 +86,38 @@ def test_manifest_is_well_formed():
|
||||
assert manifest[key].startswith("https://github.com/"), (
|
||||
f"{key} must be a public URL, got {manifest[key]}"
|
||||
)
|
||||
|
||||
|
||||
def test_todo_entity_is_named(strings):
|
||||
"""todo.py declares its name via _attr_translation_key, not a description."""
|
||||
assert attr_keys("todo.py") == set(strings["entity"]["todo"])
|
||||
|
||||
|
||||
def test_fire_action_call_sites_are_well_formed():
|
||||
"""Every fire_action call passes (action, ok, target, detail).
|
||||
|
||||
A refactor that moved this helper onto the base entity rewrote the call
|
||||
sites mechanically and left one with three arguments — a TypeError that
|
||||
only fires when a user presses the button, which no unit test reaches.
|
||||
"""
|
||||
bad = []
|
||||
for path in COMPONENT.glob("*.py"):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and getattr(node.func, "attr", None) == "fire_action"
|
||||
and len(node.args) != 4
|
||||
):
|
||||
bad.append(f"{path.name}:{node.lineno} takes {len(node.args)}")
|
||||
assert not bad, f"malformed fire_action calls: {bad}"
|
||||
|
||||
|
||||
def test_no_class_inherits_from_itself():
|
||||
"""`class X(X, Mixin)` is legal Python and a maintenance trap; it was here."""
|
||||
for path in COMPONENT.glob("*.py"):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef):
|
||||
names = {getattr(b, "id", None) for b in node.bases}
|
||||
assert node.name not in names, f"{path.name}: {node.name} inherits itself"
|
||||
|
||||
@@ -10,11 +10,10 @@ schedule and fails when the site moves.
|
||||
WHAT THIS CAN AND CANNOT SEE
|
||||
----------------------------
|
||||
Only the *unauthenticated* surface is checked here: the login handshake and the
|
||||
Algolia catalogue. The authenticated contract — dashboard markup, cart add
|
||||
hashes, skip popups, subscribe forms — needs a real session, and the only way to
|
||||
give public CI one is to put a personal grocery account's password in repo
|
||||
secrets. That is not worth it for a drift check. Those assumptions belong in a
|
||||
fleet job on a host that already has credential access; see README.
|
||||
Algolia catalogue, so it runs anywhere. The authenticated contract — dashboard
|
||||
markup, cart add hashes, skip popups, subscribe forms — needs a real session.
|
||||
tools/compat_auth.py checks that half, from a workflow that runs only on the
|
||||
maintainer's forge, where the account credentials are; see README.
|
||||
|
||||
Exit code is the number of FAILED checks, so CI fails loudly on drift.
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the markup behind the freshharvest.com login that this integration parses.
|
||||
|
||||
tools/compat.py covers what anyone can see: the sign-in form, the catalogue and
|
||||
the endpoint shapes. Everything that has actually broken so far sat behind the
|
||||
login, and every one of those breaks was SILENT:
|
||||
|
||||
* subscription rows moved -> reported 0 subscriptions
|
||||
* hold dates were not ISO -> reported 0 holds
|
||||
* a popup gained a space -> matched nothing at all
|
||||
|
||||
A sensor reading 0 looks the same as an account with nothing in it, so nobody
|
||||
notices. This signs in and asserts each of those assumptions, naming the
|
||||
symptom when one breaks.
|
||||
|
||||
Read-only: it signs in and reads pages, and never posts to a write endpoint.
|
||||
|
||||
CREDENTIALS come from the environment, FH_EMAIL and FH_PASSWORD. It refuses to
|
||||
run without both.
|
||||
|
||||
THE OUTPUT IS FOR A PUBLIC LOG (.github/workflows/compat-auth.yml). It prints
|
||||
one pass/fail label per assumption and nothing read from the account: no
|
||||
email, no page content, and no hint of the account's state. A check with
|
||||
nothing to assert against (the hold-date format when no hold is scheduled)
|
||||
prints as passing, because "no hold scheduled" in a public log would announce
|
||||
when deliveries are paused.
|
||||
|
||||
Exit codes:
|
||||
5 every assumption holds (ran, nothing to report)
|
||||
10 at least one assumption no longer holds: drift
|
||||
2 FH_EMAIL or FH_PASSWORD is missing
|
||||
1 the check could not run (portal unreachable, timeout, unexpected error)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Callable, Mapping
|
||||
from http.cookiejar import CookieJar
|
||||
|
||||
BASE = "https://freshharvest.com"
|
||||
UA = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/126.0 Safari/537.36"
|
||||
)
|
||||
TIMEOUT = 45
|
||||
|
||||
QUIET = 5 # ran, every assumption holds
|
||||
FINDING = 10 # ran, and something moved
|
||||
CANNOT_RUN = 1
|
||||
MISCONFIGURED = 2
|
||||
|
||||
# A check with nothing to assert against. Rendered as a pass; see the docstring.
|
||||
SKIP = "skip"
|
||||
|
||||
Row = tuple[str, object, str] # (assumption, True | False | SKIP, symptom)
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""The credentials are missing from the environment."""
|
||||
|
||||
|
||||
def credentials(env: Mapping[str, str]) -> tuple[str, str]:
|
||||
"""Return (email, password) from FH_EMAIL / FH_PASSWORD, or raise ConfigError."""
|
||||
email = env.get("FH_EMAIL", "").strip()
|
||||
password = env.get("FH_PASSWORD", "").strip()
|
||||
missing = [
|
||||
name
|
||||
for name, value in (("FH_EMAIL", email), ("FH_PASSWORD", password))
|
||||
if not value
|
||||
]
|
||||
if missing:
|
||||
verb = "is" if len(missing) == 1 else "are"
|
||||
raise ConfigError(f"{' and '.join(missing)} {verb} not set or empty")
|
||||
return email, password
|
||||
|
||||
|
||||
class Portal:
|
||||
"""A cookie-carrying session on freshharvest.com. The only network code here."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(CookieJar())
|
||||
)
|
||||
|
||||
def _open(self, path: str, data: bytes | None = None) -> str:
|
||||
req = urllib.request.Request(
|
||||
BASE + path, data=data, headers={"User-Agent": UA}
|
||||
)
|
||||
with self._opener.open(req, timeout=TIMEOUT) as resp:
|
||||
return resp.read().decode("utf-8", "replace")
|
||||
|
||||
def get(self, path: str) -> str:
|
||||
return self._open(path)
|
||||
|
||||
def post(self, path: str, fields: Mapping[str, str]) -> str:
|
||||
return self._open(path, urllib.parse.urlencode(fields).encode())
|
||||
|
||||
|
||||
def sign_in(portal: Portal, email: str, password: str, rows: list[Row]) -> bool:
|
||||
form = portal.get("/s/popup/login")
|
||||
hidden = dict(
|
||||
re.findall(r"name='(LoginSecurity|SubmitToken)'[^>]*value='([^']*)'", form)
|
||||
)
|
||||
if len(hidden) != 2:
|
||||
rows.append(("login form mints both anti-replay tokens", False,
|
||||
"sign-in breaks entirely"))
|
||||
return False
|
||||
rows.append(("login form mints both anti-replay tokens", True, ""))
|
||||
body = portal.post("/s/submit/login", {
|
||||
"LoginEmail": email,
|
||||
"LoginPassword": password,
|
||||
"LoginSecurity": urllib.parse.unquote(hidden["LoginSecurity"]),
|
||||
"SubmitToken": urllib.parse.unquote(hidden["SubmitToken"]),
|
||||
"Redirect": "",
|
||||
})
|
||||
ok = "sign out" in body.lower()
|
||||
rows.append(("credentials accepted", ok, "every entity goes unavailable"))
|
||||
return ok
|
||||
|
||||
|
||||
def run_checks(portal: Portal, email: str, password: str) -> list[Row]:
|
||||
rows: list[Row] = []
|
||||
|
||||
def check(assumption: str, ok: object, symptom: str = "") -> None:
|
||||
rows.append((assumption, ok, symptom))
|
||||
|
||||
if not sign_in(portal, email, password, rows):
|
||||
return rows
|
||||
|
||||
# --- the dashboard the sensors are built on --------------------------
|
||||
dash = portal.get("/p/dashboard/details")
|
||||
check("dashboard states the delivery day and next arrival",
|
||||
bool(re.search(r"Your deliveries are\s*\w+\.\s*Next Arriving:", dash)),
|
||||
"next-delivery date goes unknown")
|
||||
check("carts render as div.cart-contents[data-cart-select]",
|
||||
"cart-contents" in dash and "data-cart-select" in dash,
|
||||
"no orders parsed: every order sensor goes unknown")
|
||||
check("order totals render as #OrderTotals-<id>",
|
||||
"OrderTotals-" in dash,
|
||||
"totals go unknown while dates still work")
|
||||
check("the shopping window lives in .cart-customize-wrapper",
|
||||
"cart-customize-wrapper" in dash,
|
||||
"every order looks locked; skip and add refuse")
|
||||
check("free-delivery progress carries a max",
|
||||
bool(re.search(r"<progress[^>]+max='[\d.]+'", dash)),
|
||||
"free-delivery-remaining goes unknown")
|
||||
|
||||
# --- subscriptions: the row selector that silently returned zero -----
|
||||
subs = portal.get("/p/dashboard/manage-subscriptions")
|
||||
check("subscription rows are .account-item-container",
|
||||
"account-item-container" in subs,
|
||||
"reports 0 subscriptions, which looks like having none")
|
||||
check("subscription cells keep their semantic classes",
|
||||
"account-item-description" in subs and "account-item-history-qty" in subs,
|
||||
"subscription names/quantities go blank")
|
||||
|
||||
# --- vacation holds: the date format that silently returned zero -----
|
||||
pause = portal.get("/p/dashboard/pause-deliveries")
|
||||
check("the vacation hold form still posts to pause-range-add",
|
||||
"/s/submit/pause-range-add" in pause,
|
||||
"cannot schedule a hold")
|
||||
# Only assertable while a hold exists. With none scheduled there is no date
|
||||
# to inspect, and an unbounded search past "Upcoming Pauses" matches an ISO
|
||||
# date from anywhere else on the page: a check that fails on a healthy
|
||||
# account is worse than no check, because it trains you to ignore it.
|
||||
section = re.search(
|
||||
r"Upcoming Pauses(.{0,400}?)(?:Close Account|</section)", pause, re.DOTALL
|
||||
)
|
||||
body = section.group(1) if section else ""
|
||||
entries = re.findall(
|
||||
r"[A-Z][a-z]+,\s*[A-Z][a-z]{2}\s+\d{1,2}\s*-\s*[A-Z][a-z]+,\s*[A-Z][a-z]{2}\s+\d{1,2}",
|
||||
body,
|
||||
)
|
||||
check("hold ranges are day-name + abbreviated month, not ISO",
|
||||
SKIP if not entries else not re.search(r"\d{4}-\d{2}-\d{2}", body),
|
||||
"hold parsing silently reports none")
|
||||
|
||||
# --- popups behind every write action --------------------------------
|
||||
orders = portal.get("/p/dashboard/manage-orders")
|
||||
for kind, symptom in (
|
||||
("pause-delivery", "skip switch cannot find a delivery"),
|
||||
("donate-delivery", "donate button fails"),
|
||||
):
|
||||
check(f"{kind} popup is still offered",
|
||||
bool(re.search(rf'openPopup\("{kind}"', orders)),
|
||||
symptom)
|
||||
|
||||
# --- basket switching -------------------------------------------------
|
||||
baskets = portal.get("/p/shop/basket-types/georgia-grown-baskets")
|
||||
check("basket options offer a select-basket popup",
|
||||
bool(re.search(r'openPopup\("select-basket",\s*"', baskets)),
|
||||
"produce-box select shows no options")
|
||||
check("basket ids are exposed as data-title='basket-<id>'",
|
||||
"data-title='basket-" in baskets,
|
||||
"cannot identify which box is which")
|
||||
|
||||
# --- add-to-cart hash -------------------------------------------------
|
||||
item = portal.get("/p/shop/item/6744/bananas")
|
||||
check("orderable items expose an orderManage add hash",
|
||||
bool(re.search(r'orderManage\("add","', item)),
|
||||
"adding any item fails as though out of stock")
|
||||
check("subscribe form posts to item-frequency with popup-toggle",
|
||||
"/s/submit/item-frequency" in item and "popup-toggle" in item,
|
||||
"subscribing silently does nothing")
|
||||
return rows
|
||||
|
||||
|
||||
def exit_code(rows: list[Row]) -> int:
|
||||
"""A count of broken assumptions is not an exit code: 10 is drift, 5 is all clear."""
|
||||
return FINDING if any(ok is False for _, ok, _ in rows) else QUIET
|
||||
|
||||
|
||||
def render(rows: list[Row]) -> str:
|
||||
"""Pass/fail labels only. SKIP renders exactly like a pass (see the docstring)."""
|
||||
signed_in = any(a == "credentials accepted" and ok is True for a, ok, _ in rows)
|
||||
width = max(len(a) for a, _, _ in rows)
|
||||
lines = ["Fresh Harvest signed-in markup check", ""]
|
||||
for assumption, ok, symptom in rows:
|
||||
lines.append(f" [{'FAIL' if ok is False else 'ok '}] {assumption.ljust(width)}")
|
||||
if ok is False and symptom:
|
||||
lines.append(f" -> {symptom}")
|
||||
passed = sum(1 for _, ok, _ in rows if ok is not False)
|
||||
lines += ["", f"{passed}/{len(rows)} assumptions hold"]
|
||||
if not signed_in:
|
||||
lines.append("Sign-in failed, so nothing behind the login was checked.")
|
||||
elif exit_code(rows) == FINDING:
|
||||
lines.append("ha-freshharvest is probably reporting wrong values, not erroring.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def redact(text: str, *secrets: str) -> str:
|
||||
"""Belt and braces for a public log: no credential survives into any output."""
|
||||
for secret in secrets:
|
||||
if secret:
|
||||
text = text.replace(secret, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main(
|
||||
env: Mapping[str, str] | None = None,
|
||||
portal_factory: Callable[[], Portal] = Portal,
|
||||
) -> int:
|
||||
try:
|
||||
email, password = credentials(os.environ if env is None else env)
|
||||
except ConfigError as err:
|
||||
print(f"cannot run: {err}")
|
||||
return MISCONFIGURED
|
||||
|
||||
try:
|
||||
rows = run_checks(portal_factory(), email, password)
|
||||
except OSError as err: # URLError, HTTPError, timeouts, resets
|
||||
print(redact(f"could not reach the portal: {err}", email, password))
|
||||
return CANNOT_RUN
|
||||
except Exception as err: # noqa: BLE001 -- public log: name it, never dump state
|
||||
print(f"the check itself failed: {type(err).__name__}")
|
||||
return CANNOT_RUN
|
||||
|
||||
print(redact(render(rows), email, password))
|
||||
return exit_code(rows)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||