Make the README user-facing, and stop the compat job writing to a mirror
Upstream compatibility / compat (push) Successful in 7s
Validate / pytest (push) Successful in 7s
Validate / HACS (push) Failing after 16s
Validate / hassfest (push) Failing after 6s

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:
flan
2026-08-03 21:10:01 +00:00
parent 4a8c4bebd7
commit 70c67901b8
3 changed files with 110 additions and 148 deletions
+13 -37
View File
@@ -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'
+24 -111
View File
@@ -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
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
[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.
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
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
Unofficial and unaffiliated — not endorsed by or supported by Fresh Harvest.
+73
View File
@@ -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.