299 lines
18 KiB
Markdown
299 lines
18 KiB
Markdown
# pantry-vision
|
||
|
||
The kitchen display's backend, from [Phase 17 of the project plan](../docs/project-plan.md).
|
||
|
||
The workflow this exists for: **come home, put down the shopping bag, hold one item
|
||
up to the kitchen display's camera, the system proposes what it is and roughly how
|
||
long it keeps, you confirm (editing anything it got wrong), put it away.** The same
|
||
display then shows the resulting inventory ordered by what expires soonest, and
|
||
Grocy's recipes, on request.
|
||
|
||
- **`pantry-vision`** (this directory) — a small always-on Python HTTP service.
|
||
`POST /identify` (a photo → a proposal via an Ollama vision model), the writes
|
||
`POST /confirm` / `POST /consume` / `POST /adjust` / `POST /transfer`, the
|
||
door-sensor hook `POST /doorway-event`, and the reads `GET /inventory`,
|
||
`GET /expired`, `GET /doorway-events`, `GET /recipes`, `GET /shopping-list` (which
|
||
proxy Grocy, reshaped for a frontend). Every endpoint is bearer-token gated.
|
||
`/shopping-list` is also consumed by `hosts/door-panel/`'s dashboard (Phase 18,
|
||
"groceries running low") — it's a thin reshape of Grocy's own
|
||
`/api/stock/volatile` `missing_products`, not new inventory logic.
|
||
- **`doorway.py`** — the appliance-door half: a contact sensor fires, a camera burst
|
||
is pulled from Frigate, and what it recognised is recorded as a **hint**. See "Which
|
||
fridge is it in" below, and `docs/fridge-item-location.md` for the argument.
|
||
- **`frontend/`** — the static single-page app the kitchen display's kiosk browser
|
||
loads: the four stock-movement screens below, plus Inventory and Recipes. Vanilla
|
||
JS, no build step, no framework — same "vendored, dependency-free" choice as the
|
||
digest/admin canvas SDKs. Served read-only by a `pantry-web` nginx container
|
||
(`setup-container-host.sh`), the same role `digest-web`/`admin-web` already play for
|
||
their own hosts.
|
||
- **`hosts/kitchen-display/`** — the touch kiosk image that runs the frontend. See
|
||
that directory's own README for the device side of this.
|
||
|
||
## The four ways stock moves
|
||
|
||
Stock only ever changes in four ways, so the display has four buttons, and three of
|
||
them are camera-first.
|
||
|
||
| Screen | What it does | Grocy call underneath |
|
||
| --- | --- | --- |
|
||
| **Unload groceries** | The camera runs in a loop. Hold up an item, get a stats card — description, best-before, where to put it, and how many individual things are in the pack — confirm, and it moves straight on to the next item without you touching the screen again. | `stock/products/{id}/add` |
|
||
| **Consume article** | Hold up what you are about to eat. If the same kind is in stock under more than one brand, it asks which; if the row holds more than one unit, it asks how many. | `stock/products/{id}/consume` |
|
||
| **List expired foods** | Everything already past its date. Clear a line by scanning the item you are about to bin (or with the row's own button when the label is unreadable). Booked out as **spoiled**, not eaten — Grocy keeps those apart, and that is the only way you will ever find out what the household keeps buying and throwing away. | `stock/products/{id}/consume` with `spoiled` |
|
||
| **Edit inventory** | The keyboard-and-buttons fallback: every article, `−`/`+`, and a freeform amount. This is the screen that does *not* use the camera, on purpose — it is where you go when the camera got something wrong. | `stock/products/{id}/inventory` |
|
||
|
||
Corrections go through Grocy's *inventory-correction* endpoint rather than
|
||
consume/add, so the stock journal says "somebody fixed the number" instead of
|
||
quietly filling the household's consumption history with corrections dressed as
|
||
meals.
|
||
|
||
### Two invariants everything else rests on
|
||
|
||
**Stock is counted in individual units, never in packages.** A twelve-pack of eggs is
|
||
booked in as twelve. "How many eggs do we have" is the question people actually ask,
|
||
and it makes booking out three of them arithmetic instead of a fractions-of-a-pack
|
||
problem. The vision model's `units_per_package` is a multiplier applied once on the
|
||
confirm screen — where it is editable, and where the screen states the result ("Books
|
||
in 12 eggs") before anything is written. Nothing downstream stores it. Set
|
||
`GROCY_DEFAULT_QU_ID` to a *Piece*-like unit accordingly.
|
||
|
||
**The fold key is the brand-free product kind.** Twelve eggs of brand X and ten of
|
||
brand Y are twenty-two eggs. The vision model is asked for a brand-free `kind`
|
||
alongside the brand, `/confirm` files the Grocy product under a **product group**
|
||
named after that kind, and the folding is a read of that group — so the grouping is
|
||
visible and editable in Grocy's own UI instead of living in a second classification
|
||
scheme here. The per-brand rows are never summed away: they are what the edit screen
|
||
expands to, because "take ten off brand Y specifically" has to stay possible.
|
||
|
||
The folding itself is deliberately dumb (casefold, strip a trailing `(Brand)`,
|
||
collapse whitespace). The clever version is a synonym problem — *eggs* vs *egg* vs
|
||
*free-range eggs* — that nobody wants adjudicated by a kitchen display at 19:00. When
|
||
the model answers inconsistently you get two lines instead of one, which is visible
|
||
and fixable; a wrongly merged line is neither.
|
||
|
||
## Which fridge is it in
|
||
|
||
The household has more than one cold appliance, so "in the fridge" is not an answer.
|
||
Two mechanisms address that, and **only the first is authoritative**:
|
||
|
||
**1. Grocy locations — the record.** The confirm screen's placement choice resolves to
|
||
a real Grocy location (`PANTRY_LOCATION_*`, created by name) and rides along on the
|
||
stock add. `/inventory` reports it. `POST /transfer` moves an amount between
|
||
appliances, and the edit screen has a "Move to…" control per row. That transfer is the
|
||
part that makes locations worth recording at all: without it, a location decays into
|
||
"where it was when it was bought", which is worse than no answer because it is
|
||
confidently wrong.
|
||
|
||
**2. Doorway hints — an observation, never a fact.** A Zigbee contact sensor on each
|
||
appliance door drives a Home Assistant automation that POSTs to `/doorway-event`:
|
||
|
||
```yaml
|
||
# Home Assistant automation — one per appliance door.
|
||
trigger:
|
||
- platform: state
|
||
entity_id: binary_sensor.fridge_kitchen_door
|
||
to: "on"
|
||
action:
|
||
- service: rest_command.pantry_doorway_event
|
||
data: { appliance: "fridge-kitchen", state: "opened" }
|
||
```
|
||
|
||
`pantry-vision` answers **202 immediately** and does the work on its own thread — the
|
||
burst is up to three snapshots and a vision call each, and an appliance door is not
|
||
something to keep a home automation waiting on. It pulls frames from that appliance's
|
||
Frigate camera, stops at the first one that identifies something, and writes what it
|
||
saw to its own SQLite file with a timestamp and a confidence.
|
||
|
||
**Nothing in that path writes stock.** A camera at a door cannot tell in from out,
|
||
misses when two things are carried at once, and sees nothing behind an arm. So it
|
||
produces "camera last saw something like this at Freezer (loggia), 20 min ago — a
|
||
sighting, not a fact", which is a sentence a person can evaluate, shown next to the
|
||
location Grocy actually records. Acting on it is a tap on Move.
|
||
|
||
Configure it with `PANTRY_DOOR_APPLIANCES` (`id:Grocy location name:frigate_camera`,
|
||
camera optional) and `FRIGATE_URL` — the same Frigate `chores` already pulls snapshots
|
||
from. **An appliance with a door sensor and no camera is a legitimate configuration**
|
||
and is the recommended starting point: it still records that the door opened, which is
|
||
the half of this feature that pays for itself.
|
||
|
||
### What the camera loop actually does
|
||
|
||
`frontend/app.js` samples a 32×24 greyscale thumbnail of the video every 700 ms and
|
||
only spends an `/identify` call when the picture has **settled** (it stopped moving)
|
||
*and* **changed** since the last thing it identified. Both gates exist because vision
|
||
latency is this phase's known open risk: the frames worth spending it on are the ones
|
||
where somebody is holding something still, and the same tin must never be identified
|
||
twice because nobody moved. "Identify now" overrides both, for the shiny jar under a
|
||
downlight that never settles.
|
||
|
||
A model that is down or answering nonsense **stops the loop** rather than
|
||
re-photographing the counter at it; the unload screen drops you into the manual form
|
||
instead.
|
||
|
||
## A real network listener, unlike admin-canvas
|
||
|
||
`admin-canvas` deliberately has **no published port** — only Home Assistant, on the
|
||
same compose network, ever calls it. `pantry-vision` is different on purpose: the
|
||
kitchen display is a separate physical device on the LAN and has to reach this
|
||
service directly (there is no HA-mediation step between "hold item up to camera" and
|
||
"get an identification back" — that has to be fast and synchronous). So
|
||
`PANTRY_VISION_PORT` **is** published, and every request — including the two GETs —
|
||
requires the bearer token, as the actual boundary instead of network placement.
|
||
|
||
The same token has to be baked into the kitchen display's own build config
|
||
(`tools/build-kitchen-display-iso.sh`), not just Home
|
||
Assistant's — see that host's README.
|
||
|
||
## `/identify` never writes anything by itself
|
||
|
||
This is the one guardrail that matters most in this whole phase. The vision model's
|
||
guess — name, category, how many days until it likely goes bad — is a **proposal**,
|
||
shown on screen for the person to review and edit before anything is confirmed. Only
|
||
`/confirm`, a separate call the frontend makes after the person taps "Confirm & add,"
|
||
ever writes to Grocy. This is the same "propose, never auto-commit" rule this project
|
||
already applies to identity-merge confirmation (see the *Identity store* row in
|
||
`docs/project-plan.md` §2) — a wrong camera guess costs one tap to fix, not a wrong
|
||
fact silently written into the household's inventory.
|
||
|
||
The book-out screens follow the same rule in the other direction, and it costs a tap
|
||
there too: recognition never consumes anything by itself, an ambiguous brand is asked
|
||
about rather than picked, and "throw away" states the amount before it goes. A camera
|
||
that quietly books out the wrong yoghurt produces an inventory nobody trusts, and an
|
||
inventory nobody trusts is worth exactly as much as no inventory.
|
||
|
||
One place where that guardrail is deliberately looser: the vision model is asked to
|
||
read a **printed best-before date** off the packaging when one is legible, and that
|
||
date lands pre-filled in the confirm screen's date field. The screen always says which
|
||
it is showing — *"Date read off the packaging — check it"* versus *"Estimated from
|
||
the category"* — and the server throws out anything more than a year in the past or
|
||
ten years out, because a misread label (small print, dot-matrix ink, curved packaging)
|
||
is the single most likely failure of this feature and that is what it looks like when
|
||
it happens.
|
||
|
||
## Configure
|
||
|
||
```sh
|
||
cp pantry-vision/pantry-vision.env.example /opt/smart-home/pantry-vision/pantry-vision.env
|
||
openssl rand -hex 32 # put the result in PANTRY_VISION_TOKEN
|
||
chmod 600 /opt/smart-home/pantry-vision/pantry-vision.env
|
||
$EDITOR /opt/smart-home/pantry-vision/pantry-vision.env
|
||
```
|
||
|
||
You also need, inside Grocy's own UI (it's already running as the always-on `grocy`
|
||
container regardless of this phase): **Settings → Manage API keys** for
|
||
`GROCY_API_KEY`, and to confirm **Settings → Locations / Quantity units** actually
|
||
match `GROCY_DEFAULT_LOCATION_ID`/`GROCY_DEFAULT_QU_ID` (fresh-install defaults, not
|
||
guaranteed to match a Grocy that's already been customised).
|
||
|
||
## Pick a vision model
|
||
|
||
`OLLAMA_VISION_MODEL` defaults to `llava`, but **nothing here has confirmed that name
|
||
against a real pull** — pick a vision-capable model (`llava`, `qwen2.5vl`, or whatever
|
||
your LLM host's GPU/CPU tier can run at acceptable latency for someone standing at
|
||
the counter holding a can of beans) and:
|
||
|
||
```sh
|
||
ollama pull llava # on the LLM host, or whichever model you picked
|
||
```
|
||
|
||
Plain text models (`qwen2.5:14b-instruct`, used elsewhere in this project for
|
||
digest/Assist) **cannot see images at all** — pointing `OLLAMA_VISION_MODEL` at one
|
||
of those will not error clearly, it will just produce a useless/hallucinated
|
||
response for every photo. Latency is unmeasured; a vision pass on a CPU-only LLM
|
||
host could easily be too slow for a "hold item up to camera" interaction to feel
|
||
responsive — this needs to be measured on real hardware, not assumed.
|
||
|
||
## Grocy API assumptions — unverified against a real instance
|
||
|
||
`server.py`'s Grocy calls (`_find_or_create_product`, `_add_to_stock`,
|
||
`_consume_stock`, `_set_stock_amount`, `_stock_items` and the `/recipes` proxy) are
|
||
written against Grocy's *documented* API shape, not checked against a running
|
||
instance. In particular:
|
||
|
||
- Whether `GET /api/stock` rows carry a nested `product` object with a `name` field
|
||
by default, or need an explicit embed/expand parameter. `_stock_items` sidesteps
|
||
this by fetching `/api/objects/products` separately and joining on `product_id` —
|
||
it needs the group and location anyway, not just the name — and degrades to
|
||
`Product #<id>` if both are missing, rather than dropping the row.
|
||
- Whether `POST /api/objects/products` with just
|
||
`name`/`location_id`/`qu_id_purchase`/`qu_id_stock` is actually enough to create a
|
||
minimal product on your Grocy version, or whether it requires more fields.
|
||
- Whether `POST /api/objects/product_groups` and `POST /api/objects/locations` accept
|
||
a bare `{"name": ...}`. Both are best-effort: a failure means the product lands
|
||
without a group (so it folds on its name instead) or in `GROCY_DEFAULT_LOCATION_ID`
|
||
(so it is in the wrong place but still in stock). Neither costs you the book-in.
|
||
- Whether `POST /api/stock/products/{id}/consume` takes `spoiled` as a boolean, and
|
||
what it returns when you try to consume more than is in stock — that refusal is
|
||
passed through to the screen verbatim, on the assumption that it is a real answer
|
||
("we have fewer than you think") rather than a transport failure.
|
||
- Whether `POST /api/stock/products/{id}/inventory` takes `new_amount` as the absolute
|
||
new figure and whether it *requires* `best_before_date` when the figure goes up. The
|
||
edit screen currently sends the amount alone.
|
||
- Whether `POST /api/stock/products/{id}/transfer` takes `location_id_from` /
|
||
`location_id_to`, and — the subtle one — **which location Grocy considers a stock
|
||
entry to be in.** `/transfer` uses the *product's* location as the source, which is
|
||
what `/confirm` set. Grocy can also hold one product's stock across several
|
||
locations at once, per entry; if your instance does that, the source this sends will
|
||
sometimes be wrong and the transfer will fail rather than move the wrong thing.
|
||
Reading `/api/stock/products/{id}/entries` is the fix if it comes up.
|
||
- Whether Frigate's `/api/<camera>/latest.jpg` returns a usable still for a camera that
|
||
is idle — `doorway.py` and `chores/check.py` both assume it does, neither has called
|
||
it.
|
||
- Whether the Recipes feature (`GET /api/objects/recipes`,
|
||
`GET /api/recipes/{id}/fulfillment`) needs to be explicitly enabled/populated
|
||
before it returns anything meaningful — `_handle_recipes` degrades to
|
||
`"fulfilled": null` per-recipe on any failure rather than breaking the whole list.
|
||
|
||
Grocy exposes a live OpenAPI spec at `http://<grocy-host>:9283/api/openapi/specification`
|
||
once it's running — read that against a real instance before trusting any of the
|
||
above, and adjust `server.py` if the shapes differ.
|
||
|
||
## Deploy
|
||
|
||
Wired into `tools/setup-container-host.sh` behind
|
||
`ENABLE_PANTRY_VISION` (off by default) — see that script's `# CONFIGURATION` block
|
||
and its own README. It builds two containers: `pantry-vision` (this API) and
|
||
`pantry-web` (nginx, serves `frontend/` read-only).
|
||
|
||
## Manual verification still outstanding
|
||
|
||
1. All of the Grocy API assumptions above.
|
||
2. Real-world vision-model accuracy and latency for grocery items — untested with
|
||
any actual model or camera. The unload loop is where latency bites hardest: it is
|
||
built to feel like a queue of items rather than a queue of round trips, and if an
|
||
`/identify` takes 20 seconds it will not feel like either.
|
||
3. **Whether the model returns a stable `kind` for the same product across scans.**
|
||
This is the assumption the whole brand-folding rests on, and the one most likely
|
||
to disappoint quietly: scan the same carton of eggs five times and see whether it
|
||
says "eggs" five times. If it drifts, the fix is a fixed `kind` vocabulary in the
|
||
prompt, not more clever folding on this side.
|
||
4. **Whether printed best-before dates are read correctly or confidently invented.**
|
||
The prompt says not to guess and the server bounds the range, but neither can catch
|
||
a plausible wrong date. Check a handful against the actual packets before trusting
|
||
the pre-filled field.
|
||
5. **That a twelve-pack really does arrive as twelve.** `units_per_package` is the
|
||
one model answer that gets multiplied rather than displayed, and the failure is
|
||
silent in both directions — twelve eggs booked in as one, or one jar booked in as
|
||
twelve.
|
||
6. **Whether a doorway camera can identify anything at all.** This is the assumption
|
||
the whole `/doorway-event` path rests on, and it is not the same task as the
|
||
kitchen display's: an item in a moving hand, at ~1.5 m, in whatever light the room
|
||
has, versus one held still against a plain background 30 cm from a webcam.
|
||
`docs/fridge-item-location.md` says to test it with the kitchen's existing camera
|
||
before buying a second one, and that advice applies to this code too — if the
|
||
answer is no, the door sensors are still worth having and this half simply stays
|
||
switched off (`PANTRY_DOOR_APPLIANCES` entries without a camera).
|
||
7. **Whether the hints are read as hints.** The wording on screen ("a sighting, not a
|
||
fact") is doing real work; if in practice people treat a sighting as the answer and
|
||
stop checking, that is a design failure this code cannot detect and the feature
|
||
should be turned off rather than tuned.
|
||
3. Whether Ollama's `/api/generate` `images` field is still the right call shape for
|
||
whichever vision model you pick — some multimodal models are only exposed through
|
||
Ollama's newer `/api/chat` with a `images` field per-message instead; this was
|
||
written against `/api/generate`'s documented multimodal support and not run
|
||
against a real model.
|
||
4. CORS: `_respond`'s blanket `Access-Control-Allow-Origin: *` is fine for a
|
||
LAN-only, bearer-token-gated service with no cookies, but hasn't been checked
|
||
against a real browser's preflight behavior for the raw-image-bytes `POST
|
||
/identify` call in particular (some browsers preflight non-simple `Content-Type`s
|
||
like `image/jpeg` — `do_OPTIONS` is written to handle that but is untested).
|