Make the README user-facing, and stop the compat job writing to a mirror
The compat badge was red on bookkeeping, not on compatibility: the check passed 16/16 and the run then failed trying to commit the refreshed matrix and open a PR. That step should never have existed here — GitHub is a read-only mirror of Gitea, so anything a bot pushes is clobbered by the next sync. It now publishes the matrix to the run summary and the workflow only needs contents:read, so the badge means what it says. The README had grown into an implementation document. Endpoints, markup traps, the login handshake and the drift-detection design move to docs/internals.md; what is left is installation, entities, events, the dashboard, requirements and troubleshooting.
This commit is contained in:
@@ -26,9 +26,9 @@ on:
|
|||||||
- ".github/workflows/compat.yml"
|
- ".github/workflows/compat.yml"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
# Read-only on contents: this workflow reports, it does not write to the repo.
|
||||||
|
contents: read
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
compat:
|
compat:
|
||||||
@@ -48,42 +48,18 @@ jobs:
|
|||||||
echo "failures=$?" >> "$GITHUB_OUTPUT"
|
echo "failures=$?" >> "$GITHUB_OUTPUT"
|
||||||
cat matrix.md
|
cat matrix.md
|
||||||
|
|
||||||
- name: Refresh the matrix in README
|
- name: Publish the matrix to the run summary
|
||||||
run: |
|
run: |
|
||||||
python - <<'PY'
|
# Deliberately does NOT commit. GitHub is a read-only mirror of Gitea,
|
||||||
import pathlib, re, datetime
|
# so anything a bot pushes here is clobbered by the next sync, and
|
||||||
matrix = pathlib.Path("matrix.md").read_text().strip()
|
# opening a PR needs a repo setting that a mirror should not depend
|
||||||
stamp = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d")
|
# on. The badge should mean "is upstream still compatible", not "did
|
||||||
block = f"<!-- COMPAT:START -->\n_Last checked {stamp}._\n\n{matrix}\n<!-- COMPAT:END -->"
|
# the bot manage its own bookkeeping".
|
||||||
readme = pathlib.Path("README.md")
|
{
|
||||||
text = readme.read_text()
|
echo "## Upstream compatibility"
|
||||||
new = re.sub(r"<!-- COMPAT:START -->.*<!-- COMPAT:END -->", block, text, flags=re.S)
|
echo
|
||||||
if new != text:
|
cat matrix.md
|
||||||
readme.write_text(new)
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
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 }}
|
|
||||||
|
|
||||||
- name: Open an issue when the site has drifted
|
- name: Open an issue when the site has drifted
|
||||||
if: steps.check.outputs.failures != '0'
|
if: steps.check.outputs.failures != '0'
|
||||||
|
|||||||
@@ -94,108 +94,6 @@ Every write action fires `freshharvest_action` with `action`, `success`,
|
|||||||
`target` and `detail`, so an automation can notify on an add succeeding or a
|
`target` and `detail`, so an automation can notify on an add succeeding or a
|
||||||
skip failing.
|
skip failing.
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## 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 | ✅ | 2273 bytes |
|
|
||||||
| Login | hidden `LoginSecurity` is minted | ✅ | 154 chars |
|
|
||||||
| Login | hidden `SubmitToken` is minted | ✅ | 174 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 | ✅ | 946 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 half runs on a host that already holds
|
|
||||||
the credential.
|
|
||||||
|
|
||||||
Worth knowing if you fork this: every markup break so far has been **silent**.
|
|
||||||
Subscription rows moved and the integration reported `0` subscriptions; hold
|
|
||||||
dates were not ISO and it reported `0` holds. A sensor reading zero is
|
|
||||||
indistinguishable from an account with nothing in it, which is exactly why
|
|
||||||
these are asserted rather than left to be noticed.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
[examples/dashboard-view.yaml](examples/dashboard-view.yaml) is a ready-made tab
|
[examples/dashboard-view.yaml](examples/dashboard-view.yaml) is a ready-made tab
|
||||||
@@ -203,21 +101,36 @@ Six traps, none guessable from the outside:
|
|||||||
full box contents rendered from the attributes, and the still-changeable order.
|
full box contents rendered from the attributes, and the still-changeable order.
|
||||||
Paste it under `views:` in the raw configuration editor.
|
Paste it under `views:` in the raw configuration editor.
|
||||||
|
|
||||||
## Tests
|
## Requirements
|
||||||
|
|
||||||
|
Home Assistant 2025.2 or newer. Developed and running against 2026.7.
|
||||||
|
|
||||||
|
## 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 yarl
|
pip install beautifulsoup4 pytest yarl
|
||||||
pytest tests/
|
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.
|
|
||||||
|
|
||||||
## Compatibility
|
|
||||||
|
|
||||||
Requires Home Assistant 2025.2 or newer. Developed and running against 2026.7.
|
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
Unofficial and unaffiliated — not endorsed by or supported by Fresh Harvest.
|
Unofficial and unaffiliated — not endorsed by or supported by Fresh Harvest.
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
Reference in New Issue
Block a user