Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions community/pantry-pro/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pantrypro_inventory.json
6 changes: 6 additions & 0 deletions community/pantry-pro/.openhome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "pantrypro",
"capability_id": null,
"category": "skill",
"description": "Voice-guided pantry assistant. Tracks pantry and fridge inventory, suggests recipes from what you have (prioritizing items about to expire), alerts before food goes bad, and builds a shopping list from gaps."
}
140 changes: 140 additions & 0 deletions community/pantry-pro/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# PantryPro — Voice-Guided Pantry Assistant

![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square)
![Status](https://img.shields.io/badge/Status-Stage%201-blue?style=flat-square)

A voice-first pantry assistant for OpenHome. Track what's in the pantry and fridge, get meal ideas from what you already have (oldest food first), hear alerts before things go bad, and build a shopping list from the gaps.

**Reduce waste. Save money.**

---

## What it does

| Feature | What you say | What happens |
| --- | --- | --- |
| **Track inventory** | "add milk to the fridge, expires Friday" | Logs item, location, quantity, and best-by date |
| **Smart suggestions** | "what can I cook?" | Matches stock to real recipes (TheMealDB), prioritizing food that's about to expire |
| **Expiration alerts** | (automatic) | Background daemon speaks up when items expire today, tomorrow, or within 3 days |
| **Shopping made easy** | "we're out of pasta" | Removes it from stock and offers to put it on the grocery list; missing recipe ingredients can be added the same way |

No API key required. Recipes use the free [TheMealDB](https://www.themealdb.com) API, with an LLM fallback if the network is down.

---

## Suggested trigger words

Dashboard triggers must be **at least 4 letters**. Recommended:

`pantry`, `pantry pro`, `what's in the fridge`, `what's in the pantry`, `what's expiring`, `what can I cook`, `recipe ideas`, `food inventory`, `grocery run`

In-session phrases (after PantryPro is already running):

| Phrase | What it does |
| --- | --- |
| `"add pasta and canned beans"` | Add items (asks for a date if you skip one) |
| `"check the fridge"` / `"what's in stock"` | Read back inventory |
| `"what's going bad"` | Items expiring within 5 days |
| `"I used the last of the milk"` | Remove + offer to restock on the shopping list |
| `"what can I cook"` | 3 meal ideas from current stock |
| `"what's on my shopping list"` | Read the list |
| `"clear the shopping list"` | Confirm, then wipe it |
| `"done"` | Save and return to the Agent |

---

## Example conversation

**User:** “Pantry pro.”

**PantryPro:** “You have pasta, tomato sauce, and canned beans. Want recipe ideas?”

**User:** “Yes.”

**PantryPro:** “One sec, matching what you've got to some meals.”

**PantryPro:** “Using tomato sauce before it goes. I can do 1, Pasta and Beans. 2, Tomato and Herb Pasta. 3, Bean chilli. Pick a number, or say skip.”

**User:** “1.”

**PantryPro:** “Pasta and Beans. You're missing onion and garlic. Add those to the shopping list?”

**User:** “Yes.”

**PantryPro:** “Added onion and garlic to the shopping list.”

**User:** “We're out of milk.”

**PantryPro:** “Removed milk. Add milk to the shopping list?”

**User:** “Yes. Done.”

**PantryPro:** “Saved. 3 items in stock, 3 on the shopping list.”

Background, later that session:

**PantryPro:** “Heads up — yogurt in the fridge expires tomorrow. Want a recipe that uses it? Say pantry pro.”

---

## How it works

1. Trigger with `pantry` (or a specific ask like “what's expiring”).
2. Inventory loads from persistent storage (`pantrypro_inventory.json`).
3. A specific ask is handled immediately (quick mode). A bare “pantry pro” greets with what's on hand and offers recipes.
4. Natural speech is classified by the LLM — add, used-up, list, recipes, shopping, tips.
5. Recipe search hits TheMealDB using your soonest-to-expire ingredient, then compares the ingredient list to stock.
6. Say **done** to hand control back. The background daemon keeps watching expiry dates for the rest of the session.

### Background daemon

Runs while the Agent session is alive. Checks every 5 minutes (90-second startup grace so it doesn't talk over boot).

| Days to expiry | What happens |
| --- | --- |
| 3 days | First heads-up (once per day) |
| 1 day / today | Daily urgent alert |
| Already expired | Daily reminder until you remove it |

Alerts are grouped: *“Urgent — 2 items need using: milk expires today and spinach expires tomorrow.”*

---

## Setup

1. Install the ability and set dashboard triggers (see above).
2. No API keys or extra config.
3. Talk to it. First run starts empty — log a few items to get recipe ideas and alerts.

---

## Project layout

```
community/pantry-pro/
├── README.md
├── .openhome.json
├── main.py # voice skill
├── background.py # expiry alerts
└── __init__.py
```

Runtime (user storage, not shipped): `pantrypro_inventory.json`

---

## Related

Nearby kitchen abilities — PantryPro is the persistent *stock + expiry* layer, not a duplicate of these:

- [`community/grocery-list-manager`](../grocery-list-manager/) — shopping list only
- [`community/mealmate-ability`](../mealmate-ability/) — recipe search; you list ingredients each time
- [`community/smart-sous-chef`](../smart-sous-chef/) — hands-free cook-along
- [`community/recipe-coach`](../recipe-coach/) — LLM-generated walkthroughs
- [`community/food-water-log`](../food-water-log/) — what you *ate*, not what's on the shelf

---

## Status

Stage 1 is live-testable: add/remove stock → expiry dates → recipe ideas from inventory → shopping gaps → background alerts. Cook-along steps stay in Mealmate / Smart Sous Chef / Recipe Coach.
Empty file.
171 changes: 171 additions & 0 deletions community/pantry-pro/background.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
from datetime import datetime
from zoneinfo import ZoneInfo

from src.agent.capability import MatchingCapability
from src.agent.capability_worker import CapabilityWorker
from src.main import AgentWorker

import json

STORAGE_FILE = "pantrypro_inventory.json"
POLL_INTERVAL = 300.0
STARTUP_GRACE = 90
# tightest matching window first: expired, tomorrow, 3 days out
NUDGE_THRESHOLDS = [0, 1, 3]


def _empty_data() -> dict:
return {"items": [], "shopping": []}


def _join_and(parts: list) -> str:
parts = [p for p in parts if p]
if not parts:
return ""
if len(parts) == 1:
return parts[0]
if len(parts) == 2:
return f"{parts[0]} and {parts[1]}"
return ", ".join(parts[:-1]) + f", and {parts[-1]}"


def _format_days(days: int) -> str:
if days < 0:
return "already expired"
if days == 0:
return "expires today"
if days == 1:
return "expires tomorrow"
return f"expires in {days} days"


class PantryProBackground(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
background_daemon_mode: bool = False

# do not change following tag of register capability
# {{register capability}}

def call(self, worker: AgentWorker, background_daemon_mode: bool):
self.worker = worker
self.background_daemon_mode = background_daemon_mode
self.capability_worker = CapabilityWorker(self.worker)
self.worker.session_tasks.create(self.watch_loop())

def _today(self):
try:
tz = ZoneInfo(self.capability_worker.get_timezone())
return datetime.now(tz).date()
except Exception:
return datetime.now().date()

def _days_until(self, expires: str) -> int:
if not expires:
return 9999
try:
exp = datetime.strptime(expires[:10], "%Y-%m-%d").date()
return (exp - self._today()).days
except Exception:
return 9999

def _threshold_for(self, days: int) -> int:
for threshold in NUDGE_THRESHOLDS:
if days <= threshold:
return threshold
return -1

async def _load(self) -> dict:
try:
if await self.capability_worker.check_if_file_exists(STORAGE_FILE, False):
raw = await self.capability_worker.read_file(STORAGE_FILE, False)
parsed = json.loads(raw)
if isinstance(parsed, dict):
parsed.setdefault("items", [])
parsed.setdefault("shopping", [])
return parsed
except Exception as e:
self.worker.editor_logging_handler.error(f"[PantryProBG] load failed: {e}")
return _empty_data()

async def _save(self, data: dict):
try:
await self.capability_worker.delete_file(STORAGE_FILE, False)
await self.capability_worker.write_file(
STORAGE_FILE, json.dumps(data), False
)
except Exception as e:
self.worker.editor_logging_handler.error(f"[PantryProBG] save failed: {e}")

def _alert_line(self, item: dict, days: int) -> str:
name = item.get("name", "food")
loc = item.get("location") or ""
where = f" in the {loc}" if loc else ""
return f"{name}{where} {_format_days(days)}"

async def watch_loop(self):
self.capability_worker.resume_normal_flow()
self.worker.editor_logging_handler.info("[PantryProBG] daemon started")
started_at = datetime.now().timestamp()

while True:
try:
daemon_age = datetime.now().timestamp() - started_at
if daemon_age <= STARTUP_GRACE:
await self.worker.session_tasks.sleep(POLL_INTERVAL)
continue

data = await self._load()
items = data.get("items") or []
if not items:
await self.worker.session_tasks.sleep(POLL_INTERVAL)
continue

today = self._today().isoformat()
nudge_items = []
changed = False

for item in items:
days = self._days_until(item.get("expires", ""))
threshold = self._threshold_for(days)
if threshold == -1:
continue

last_date = item.get("last_nudge_date", "")
if last_date != today:
nudge_items.append((item, days))
item["last_nudge_date"] = today
item["last_nudge_threshold"] = threshold
changed = True

if changed:
await self._save(data)

if not nudge_items:
await self.worker.session_tasks.sleep(POLL_INTERVAL)
continue

urgent = any(d <= 1 for _, d in nudge_items)
prefix = "Urgent" if urgent else "Heads up"
lines = [self._alert_line(item, days) for item, days in nudge_items[:3]]
if len(nudge_items) == 1:
msg = f"{prefix} — {lines[0]}. Want a recipe that uses it? Say pantry pro."
else:
extra = f" Plus {len(nudge_items) - 3} more." if len(nudge_items) > 3 else ""
msg = (
f"{prefix} — {len(nudge_items)} items need using: "
f"{_join_and(lines)}.{extra} Say pantry pro for recipe ideas."
)

self.worker.editor_logging_handler.info(
f"[PantryProBG] alerting: {[i.get('name') for i, _ in nudge_items]}"
)
await self.capability_worker.send_interrupt_signal()
await self.capability_worker.speak(msg)

except Exception as e:
self.worker.editor_logging_handler.error(f"[PantryProBG] loop error: {e}")
await self.worker.session_tasks.sleep(60.0)
continue

await self.worker.session_tasks.sleep(POLL_INTERVAL)
Loading
Loading