SmartestHome/digest-engine/ingest/grocy.py

290 lines
11 KiB
Python

"""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