SmartestHome/pantry-vision
Amir Alexander Abdelbaki ea82ee70ad Add tools/ and CoreSystemConfig.json — one source of truth for every build
Installation was six scripts each carrying its own copy of the container host's
IP, three that had to agree on IDENTITY_TOKEN, and every service URL typed by
hand with a port in it. Any one could be wrong, and the symptom was always the
same and always late: an image that boots fine and then can't reach something,
found after a 40-minute build and a reboot.

Two properties fix that class of bug:

- Nothing is written twice. No script in tools/ contains an IP, port or token.
- Anything derivable is derived. You give the subnet prefix once and one last
  octet per host; every address and service URL is computed from those.

THE TWINNED PAIR. container_host.ip_last_octet 12 and llm_host 13 mean the
container host's OLLAMA_HOST *is* http://<prefix>.13:11434 — computed in the
same build, not typed into two files and kept in sync. Move the LLM host to .21
and the container host's Ollama URL follows; change the subnet and both halves
move along with every kiosk's URLs. Neither image can be built pointing at an
address the other isn't using. Both carry the same SMARTHOME_PAIR_ID (a hash of
the config's meaning, not its bytes) so two USB sticks can be checked against
each other later.

validate-config.py runs before every build and refuses to start on an error, so
a mistake costs seconds not an hour. It catches duplicate ports (including the
music_assistant/pantry_vision 8095 clash that Compose can't see because MA runs
network_mode:host — open decision #31), both hosts on one address, duplicate
hostnames across kiosks and audio endpoints, placeholder tokens (checked before
the length check, so padding "changeme" to 32 chars doesn't pass), a private key
pasted where the public one goes, and a kiosk pointed at a disabled service.

build-all.sh is the normal entry point — the images are a set that has to agree
with itself, so building one is the exception. It builds the core pair, every
kiosk, and every audio endpoint including both architectures (amd64 live-build
ISO and arm64 rpi-image-gen img are different toolchains, not one image).

The two new host ISOs install unattended with everything burnt in, including
service env files generated from derived values — which permanently removes the
class of bug that had chores.env shipping IDENTITY_URL=http://127.0.0.1:8097.
setup-container-host.sh and setup-llm-host.sh now read every config value as
${VAR:-default} so the images configure them without editing.

That also makes every ISO a credential: Wi-Fi PSK, tokens, MQTT and HA
credentials are readable by anyone holding the stick. .gitignore covers the
filled-in CoreSystemConfig.json and build-output/.

Tested: 43 config validation/derivation checks and 44 builder checks against the
real code paths with only `lb` stubbed — every generated env file, preseed,
network config, first-boot unit and build stamp is verified, including that a
port collision refuses the build before writing anything. No ISO has been built;
`lb build` needs live-build, root and a long fetch. tools/README.md says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:22:25 +02:00
..
frontend Add Phase 16 touch panel and Phase 17 kitchen/fridge display endpoints 2026-07-30 11:33:08 +02:00
Dockerfile Add Phase 16 touch panel and Phase 17 kitchen/fridge display endpoints 2026-07-30 11:33:08 +02:00
README.md Add tools/ and CoreSystemConfig.json — one source of truth for every build 2026-07-31 13:22:25 +02:00
pantry-vision.env.example Add Phase 16 touch panel and Phase 17 kitchen/fridge display endpoints 2026-07-30 11:33:08 +02:00
server.py Add identity registration system and Phase 18 door panel 2026-07-30 13:40:04 +02:00

README.md

pantry-vision

The kitchen display's backend, from Phase 17 of the project plan.

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), POST /confirm (a human-reviewed proposal → written into Grocy stock), GET /inventory, GET /recipes, and GET /shopping-list (all three proxy Grocy, reshaped for a frontend). All five endpoints are 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.
  • frontend/ — the static single-page app the kitchen display's kiosk browser loads: Scan / Inventory / 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.

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.

Configure

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:

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, the /inventory and /recipes proxies) 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 — _handle_inventory degrades to Product #<id> if not, rather than dropping the row, but that's a fallback, not a fix.
  • 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 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.
  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-Types like image/jpegdo_OPTIONS is written to handle that but is untested).