290 lines
11 KiB
Python
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
|