"""Grocy ingestion — expiring stock, missing stock, chores and battery charges. READ-ONLY INVARIANT: this module issues GET requests and nothing else, ever. Grocy's API is a full read/write API and the write endpoints are trivially reachable with the same key — `POST /api/stock/products/{id}/{add,consume, transfer,inventory,open}`, `POST /api/chores/{id}/execute`, `POST /api/batteries/{id}/charge`, and the whole `POST /api/stock/shoppinglist/*` family (`add-missing-products`, `add-overdue-products`, `add-expired-products`, `add-product`, `remove-product`, `clear`) plus `POST /api/recipes/{id}/ add-not-fulfilled-products-to-shoppinglist`. None of them are used here and none of them may ever be, per docs/project-plan.md Phase 12 step 8. That applies in particular to the evening recipe/shopping-list feature in synth/prompts/household.md: the suggested shopping list is rendered in the digest for a human to act on, and is NEVER pushed into Grocy's own shopping list. Sourcing, verified against grocy.openapi.json on grocy/grocy master 2026-07-28: `GET /api/stock/volatile?due_soon_days=N` is the direct answer to "what is about to go off". It returns one object with four arrays — `due_products` (within N days), `overdue_products` (past a best-before date), `expired_products` (past a hard expiration date) and `missing_products` (below min_stock_amount). Reconstructing that from `/api/objects/products` plus stock entries is unnecessary. Note the naming: Grocy renamed `expiring_products` -> `due_products` in v3.0.0, so older third-party examples showing `expiring_products` are wrong against a current install. The first three arrays are `CurrentStockResponse` objects: `product_id`, `amount`, `best_before_date` (documented as "the next due date for this product", not necessarily a best-before) and a nested `product`. `missing_products` is a different, smaller shape: `id`, `name`, `amount_missing`, `is_partly_in_stock`. `GET /api/stock` gives everything currently in stock. It is here only so the evening shopping-list suggestion can tell "we already have this" from "buy this" — see the note on GROCY_MAX_STOCK_ITEMS below. `GET /api/chores` and `GET /api/batteries` return next-execution/next-charge estimates. Both use `2999-12-31 23:59:59` as the "no schedule" sentinel (chores with period_type `manually`, batteries with no charge_interval_days), which is filtered out here rather than reported as a due date in the year 2999. `/api/batteries` returns only `battery_id`, so names come from `/api/objects/batteries`. Auth is a per-user API key in a `GROCY-API-KEY` header (the OpenAPI spec's only security scheme). Generate one in Grocy at Settings -> Manage API keys (`/manageapikeys`). There is no OAuth and no scoping: a Grocy API key carries that user's full read *and* write rights, so the read-only guarantee above comes from this code, exactly as it does for the OPNsense key. Reachability: the deployed Grocy is `lscr.io/linuxserver/grocy` as service `grocy` on port 80 internally (published as 9283), on the same default compose network as digest-engine — so the default GROCY_URL is the container name, and no host networking or extra compose plumbing is needed. """ import logging import os from datetime import date, datetime import requests LOG = logging.getLogger(__name__) DEFAULT_URL = "http://grocy" HTTP_TIMEOUT = 30 DEFAULT_DUE_SOON_DAYS = 5 DEFAULT_TASK_HORIZON_DAYS = 7 DEFAULT_MAX_STOCK_ITEMS = 200 # Grocy's "this never happens" sentinel date, used by chores with a manual period # and by batteries with no charge interval. NEVER_YEAR = 2999 def _get(session, base_url, path, params=None, default=None): try: response = session.get(base_url + path, params=params, timeout=HTTP_TIMEOUT) response.raise_for_status() return response.json() except Exception: LOG.warning("grocy: GET %s failed, continuing without it", path, exc_info=True) return default def _num(value): try: return float(value) except (TypeError, ValueError): return None def _parse_when(raw): text = str(raw or "").strip() if not text: return None try: parsed = datetime.fromisoformat(text) except ValueError: return None return None if parsed.year >= NEVER_YEAR else parsed def _index(rows): return {str(row.get("id")): row for row in rows or [] if row.get("id") is not None} def _unit_label(units, qu_id, amount): unit = units.get(str(qu_id)) if not unit: return None if amount == 1: return unit.get("name") return unit.get("name_plural") or unit.get("name") def _product_of(row, products): nested = row.get("product") or {} stored = products.get(str(row.get("product_id") or nested.get("id"))) or {} # Merged rather than either/or: /api/stock nests the product, /api/objects/ # shopping_list only carries a product_id, and either request may have failed. return {**nested, **stored} if stored else nested def _stock_entry(row, products, units, category, status, today): product = _product_of(row, products) amount = _num(row.get("amount")) due = _parse_when(row.get("best_before_date")) return { "source": "grocy", "category": category, "status": status, "product": product.get("name") or f"product #{row.get('product_id')}", "amount": amount, "unit": _unit_label(units, product.get("qu_id_stock"), amount), "due_date": due.date().isoformat() if due else None, "days_until_due": (due.date() - today).days if due else None, } def _volatile_entries(volatile, products, units, today): entries = [] for key, status in ( ("due_products", "due_soon"), ("overdue_products", "overdue"), ("expired_products", "expired"), ): for row in volatile.get(key) or []: entries.append(_stock_entry(row, products, units, "expiring_stock", status, today)) for row in volatile.get("missing_products") or []: entries.append( { "source": "grocy", "category": "missing_stock", "product": row.get("name") or f"product #{row.get('id')}", "amount_missing": _num(row.get("amount_missing")), "partly_in_stock": bool(_num(row.get("is_partly_in_stock"))), } ) return entries def _stock_entries(stock, products, units, today, limit): rows = sorted(stock or [], key=lambda row: str((_product_of(row, products)).get("name") or "")) if len(rows) > limit: LOG.warning( "grocy: %d products in stock, only the first %d are reported — raise " "GROCY_MAX_STOCK_ITEMS or the evening shopping list may suggest buying " "something you already have", len(rows), limit, ) rows = rows[:limit] return [_stock_entry(row, products, units, "in_stock", "in_stock", today) for row in rows] def _chore_entries(chores, today, horizon_days): entries = [] for row in chores or []: due = _parse_when(row.get("next_estimated_execution_time")) if due is None: continue days = (due.date() - today).days if days > horizon_days: continue assigned = row.get("next_execution_assigned_user") or {} entries.append( { "source": "grocy", "category": "chore", "name": row.get("chore_name") or f"chore #{row.get('chore_id')}", "due_at": due.isoformat(), "days_until_due": days, "last_done": row.get("last_tracked_time"), "assigned_to": assigned.get("display_name") or assigned.get("username"), } ) return entries def _battery_entries(batteries, meta, today, horizon_days): entries = [] for row in batteries or []: due = _parse_when(row.get("next_estimated_charge_time")) if due is None: continue days = (due.date() - today).days if days > horizon_days: continue battery = meta.get(str(row.get("battery_id"))) or {} entries.append( { "source": "grocy", "category": "battery", "name": battery.get("name") or f"battery #{row.get('battery_id')}", "used_in": battery.get("used_in"), "due_at": due.isoformat(), "days_until_due": days, "last_charged": row.get("last_tracked_time"), } ) return entries def _shopping_entries(items, products, units): entries = [] for row in items or []: product = _product_of(row, products) amount = _num(row.get("amount")) entries.append( { "source": "grocy", "category": "shopping_list", "product": product.get("name") or (row.get("note") or "").strip() or "unnamed item", "amount": amount, "unit": _unit_label(units, product.get("qu_id_stock"), amount), "note": (row.get("note") or "").strip() or None, } ) return entries def fetch(lookback_hours): del lookback_hours # Grocy state is a snapshot of right now, not a time window base_url = os.environ.get("GROCY_URL", DEFAULT_URL).strip().rstrip("/") api_key = os.environ.get("GROCY_API_KEY", "").strip() if not api_key: LOG.warning("grocy: GROCY_API_KEY not set, skipping (create one at Grocy -> Manage API keys)") return [] due_soon_days = int(os.environ.get("GROCY_DUE_SOON_DAYS", DEFAULT_DUE_SOON_DAYS)) horizon_days = int(os.environ.get("GROCY_TASK_HORIZON_DAYS", DEFAULT_TASK_HORIZON_DAYS)) max_stock_items = int(os.environ.get("GROCY_MAX_STOCK_ITEMS", DEFAULT_MAX_STOCK_ITEMS)) session = requests.Session() session.headers.update({"GROCY-API-KEY": api_key, "Accept": "application/json"}) volatile = _get(session, base_url, "/api/stock/volatile", {"due_soon_days": due_soon_days}) if not isinstance(volatile, dict): # Bail on the first call rather than letting seven more requests each burn # their own connect timeout: if this one failed, Grocy is unreachable or the # key is wrong, and the rest will fail identically. LOG.warning("grocy: the stock query failed, skipping the source entirely") return [] products = _index(_get(session, base_url, "/api/objects/products", default=[])) units = _index(_get(session, base_url, "/api/objects/quantity_units", default=[])) today = date.today() entries = _volatile_entries(volatile, products, units, today) entries += _stock_entries(_get(session, base_url, "/api/stock", default=[]), products, units, today, max_stock_items) entries += _chore_entries(_get(session, base_url, "/api/chores", default=[]), today, horizon_days) entries += _battery_entries( _get(session, base_url, "/api/batteries", default=[]), _index(_get(session, base_url, "/api/objects/batteries", default=[])), today, horizon_days, ) entries += _shopping_entries( _get(session, base_url, "/api/objects/shopping_list", default=[]), products, units ) expiring = sum(1 for entry in entries if entry["category"] == "expiring_stock") LOG.info("grocy: %d entr(ies), %d of them expiring stock", len(entries), expiring) return entries