How to Build a Local Meal Assistant Integration for Home Assistant
Build a Home Assistant custom integration that reads Grocy stock, plans meals with controlled substitutions, and safely deducts the ingredients you cook.
On this page, 24 sections
- The architecture
- Prerequisites
- Create the integration structure
- Add a UI config flow and options
- Define a canonical ingredient registry
- Normalize packages and units once
- Make product classification deliberately conservative
- Create a versioned recipe format
- Rank substitutions separately
- Use ingredient importance for controlled leniency
- Allocate against one temporary stock ledger
- Fetch once with a data coordinator
- Re-plan immediately before deducting a meal
- Expose small entities and validated actions
- Save custom recipes atomically
- Add safe free-text inventory consumption
- Add Home Assistant Assist only after the actions are safe
- Build a local meal dashboard
- Set up and unload the integration cleanly
- Test policy, conversions, and failure behavior
- Install it manually before distributing it
- Package it for HACS
- Common mistakes
- Where to take it next
Most recipe apps start with a recipe and hand you a shopping list. I wanted the opposite: start with the food that’s already in the house, work out what I can actually cook right now, tell me what I’d be compromising on, and knock the ingredients off the count once I’ve made it. That’s what the Meal Assistant on my reflashed Echo Show does, and this is how you’d build your own.
It takes more than searching recipe text. “One can of beans,” “400 g tomatoes,” and “half a package of pasta” might all be sitting in Grocy under different stock units. A recipe can survive a missing garnish but not a missing protein. Parmesan can stand in for pecorino. But a bottle of whiskey butter should never get mistaken for the regular kind.
This guide builds a local Home Assistant custom integration around those constraints. Grocy stays the inventory source of truth. Home Assistant handles setup, polling, actions, entities, dashboard cards, and optional voice. A deterministic Python engine does the ingredient classification, unit conversion, substitutions, recipe scoring, and allocation.
The examples come from a system I run, generalized for public use - there are no private addresses, API keys, product IDs, or my household’s recipes in here. Mine is a vibe-coded one-off you can’t just download, so this is a blueprint for building your own, not an install guide.
Grocy stays the inventory source of truth, a deterministic Python engine does the classification, unit conversion, substitutions, and scoring, every recipe is allocated against one temporary stock ledger, and nothing is deducted until a live re-plan right before the write.
Food-safety note: Inventory software can track quantities and dates, but it cannot determine whether food is safe to eat. Prefer printed dates, respect storage guidance, and discard food that was stored improperly or appears questionable.
The architecture
Grocy REST API
|
v
async inventory client ---> canonical stock quantities
|
v
DataUpdateCoordinator
|
+-- ingredient registry
+-- package and unit conversions
+-- ranked substitutions
+-- recipe catalog
+-- leniency and allocation engine
|
+--> small Home Assistant sensors
+--> validated Home Assistant actions
+--> local meal and planner cards
+--> optional Assist tools
Keep the Home Assistant integration layer thin. It should know how to load a config entry, share an HTTP session, schedule updates, expose entities, register actions, and serve frontend resources. It should not contain hundreds of ingredient rules inside entity classes.
Put the meal-planning domain in ordinary Python modules with plain data models. Splitting it that way gives you fast unit tests, and it keeps a Home Assistant API change from forcing a rewrite of the recipe engine.
Prerequisites
- Home Assistant with access to its
configdirectory. - A local Grocy installation and Grocy API key.
- Products and stock already tracked in Grocy.
- Python and JSON familiarity.
- A test Grocy database, disposable products, or a full backup for consumption testing.
Grocy publishes a machine-readable OpenAPI specification. Check it against your installed Grocy version when implementing fetch and consume requests.
Create the integration structure
Home Assistant loads custom integrations from <config>/custom_components/<domain>. Follow the current integration file-structure documentation for required files and naming.
custom_components/
└── meal_assistant/
├── __init__.py
├── manifest.json
├── const.py
├── config_flow.py
├── coordinator.py
├── api.py
├── models.py
├── catalog.py
├── engine.py
├── recipe_store.py
├── resolver.py
├── sensor.py
├── actions.py
├── inventory_intent.py
├── llm.py
├── services.yaml
├── strings.json
├── translations/
│ └── en.json
├── frontend/
│ └── meal-assistant-card.js
└── data/
├── ingredients.json
├── substitutions.json
└── recipes.json
The voice files are optional. Build and test the inventory, engine, and actions before adding them.
A practical manifest.json looks like this:
{
"domain": "meal_assistant",
"name": "Meal Assistant",
"codeowners": ["@your-github-name"],
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://example.com/meal-assistant",
"integration_type": "service",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/your-name/meal-assistant/issues",
"requirements": [],
"single_config_entry": true,
"version": "0.1.0"
}
Home Assistant requires a version for custom integrations. single_config_entry is useful for one household Grocy instance; remove it only if you intentionally support several servers. Review the current manifest reference before each release.
Add a UI config flow and options
The initial config flow should request only the stable connection information:
- Grocy URL, such as
http://grocy.local. - Grocy API key.
Validate both by calling Grocy’s system-information endpoint before creating the entry. Normalize the URL, create a unique ID from it, and reject accidental duplicates.
Put behavior that users may change in an options flow:
- Scan interval.
- Recipe leniency points.
- Whether component recipes appear beside full meals.
- Optional custom recipe path, if you support more than the default.
class MealAssistantConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
async def async_step_user(self, user_input=None):
errors = {}
if user_input is not None:
url = normalize_grocy_url(user_input[CONF_URL])
client = GrocyClient(
async_get_clientsession(self.hass),
url,
user_input[CONF_API_KEY],
)
try:
await client.async_validate()
except InvalidAuth:
errors["base"] = "invalid_auth"
except CannotConnect:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(url)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Meal Assistant",
data={**user_input, CONF_URL: url},
)
return self.async_show_form(
step_id="user",
data_schema=CONFIG_SCHEMA,
errors=errors,
)
Add reauthentication so a replaced API key can be repaired in place. Keep labels and errors in strings.json and translations rather than embedding user-facing text in the flow. Home Assistant’s config-flow guide shows the current patterns.
Define a canonical ingredient registry
Grocy product names are household-specific. Recipes should not depend on those names. Put a canonical ingredient registry between them:
{
"id": "long-grain-rice",
"name": "Long-grain rice",
"unit": "cup",
"family": "rice",
"patterns": ["\\blong[- ]grain rice\\b", "\\bjasmine rice\\b"],
"exclude_patterns": ["\\brice vinegar\\b", "\\brice cereal\\b"]
}
Every ingredient needs:
- A stable ID.
- A human name.
- One canonical recipe unit.
- Conservative positive patterns.
- Exclusion patterns for likely false matches.
- Optional family or parent data for substitutions.
- Optional package-capacity rules.
Prefer an explicit marker in the Grocy product description:
meal-assistant: long-grain-rice
meal-assistant-capacity: 8 cup
The marker removes ambiguity. The capacity says how much one Grocy stock unit represents. Name-based inference is just a fallback for ordinary products - don’t build the system on it.
The registry loader should reject duplicate IDs, invalid patterns, unsupported units, missing families, and contradictory capacities before Home Assistant starts planning meals.
Normalize packages and units once
Grocy may say you own 2 packages while a recipe asks for 1.5 cups. The engine needs a canonical amount and a conversion back to Grocy units.
Represent normalized stock explicitly:
@dataclass(frozen=True)
class StockItem:
product_id: int
product_name: str
ingredient_id: str
available: Decimal
canonical_unit: str
grocy_units_per_canonical_unit: Decimal
location_name: str | None = None
For each product, establish package capacity in this order:
- Explicit product-description metadata.
- A carefully parsed package size in the product name.
- A registry rule that is specific to that product class.
- No match - exclude it and report why.
Support only conversions you can define. Mass, volume, and count are not interchangeable without ingredient-specific density or yield data. 16 oz pasta can convert to pounds by mass; it cannot become cups using a universal kitchen constant.
Common useful conversions include:
- Grams, kilograms, ounces, and pounds within mass.
- Milliliters, liters, fluid ounces, cups, tablespoons, and teaspoons within volume.
- Items, cans, packages, and slices when an explicit package capacity exists.
Use Decimal for stock math. Round only at the Grocy API boundary and make sure rounding cannot overdraw inventory.
Make product classification deliberately conservative
False positives are worse than missing suggestions. If the integration cannot distinguish butter from whiskey butter, it should leave the product unclassified and show a repair message.
Classification should use:
- An explicit canonical marker.
- Exact normalized aliases.
- Positive regular-expression patterns.
- Exclusion patterns.
- A specificity score and an ambiguity margin.
Never select the first substring match. A useful registry should explicitly defend against collisions such as:
waterandtonic water.creamandcream soda.stockandstock concentrate.riceandrice vinegar.butterand flavored compound butter.orangeand orange liqueur.
Log unmatched and ambiguous products at a useful diagnostic level without logging API keys or excessive personal data.
Create a versioned recipe format
Recipe imports and user recipes need the same schema. A version number lets you migrate safely later.
{
"format_version": 1,
"recipes": [
{
"id": "quick-tomato-pasta",
"name": "Quick Tomato Pasta",
"kind": "meal",
"effort": 2,
"minutes": 25,
"servings": 2,
"ingredients": [
{
"ingredient": "dry-pasta",
"amount": 8,
"unit": "ounce",
"importance": "essential"
},
{
"ingredient": "canned-tomatoes",
"amount": 14,
"unit": "ounce",
"importance": "essential"
},
{
"ingredient": "parmesan",
"amount": 0.25,
"unit": "cup",
"importance": "supporting"
}
],
"instructions": [
"Cook the pasta.",
"Simmer the tomatoes and combine."
],
"tags": ["weeknight", "pantry"]
}
]
}
Validate at least:
format_versionis supported.- Recipe IDs are unique, stable kebab-case strings.
effortis within your documented range.- Minutes and servings are positive.
kindismealorcomponent.- Every ingredient ID exists and appears once per recipe.
- Amounts are positive and units can convert to the ingredient’s canonical unit.
- Importance is one of
essential,important,supporting, oroptional. - A component has useful completion ideas.
- A source, when present, is a valid URL or plain attribution.
Keep substitution logic out of recipes. A recipe should just describe the dish it’s meant to be; the global pantry policy lives somewhere else.
Rank substitutions separately
A substitution is directional. Parmesan may replace pecorino in a weeknight pasta with a modest fidelity penalty, but that does not automatically mean pecorino should replace parmesan everywhere.
{
"from": "pecorino-romano",
"to": "parmesan",
"rank": 10,
"fidelity": 0.88,
"multiplier": 1.0
}
For each missing requirement, search exact products first, then substitutions in rank order. Carry the substitution path and fidelity into the resulting plan so the dashboard can explain what changed.
Do not add a broad substitution simply to increase the available-meal count. I’d rather ship a short list of substitutions I can defend than a long one full of recipes that don’t taste like themselves anymore.
Use ingredient importance for controlled leniency
A Meal Assistant needs a middle state between “perfectly available” and “impossible.” Assign a miss cost to each importance level:
| Importance | Example cost | Meaning |
|---|---|---|
essential | 99 | Missing it makes this a different or impossible dish. |
important | 3 | The meal is substantially compromised. |
supporting | 1 | The recipe still works, with a visible omission. |
optional | 0 | Garnish or enhancement. |
Then give the user a leniency threshold, perhaps 2 points by default:
- Ready: every required allocation is available.
- Flexible: missing cost is within the threshold.
- Unavailable: missing cost exceeds the threshold or an essential item is absent.
There’s nothing universal about these numbers - they’re just the policy I landed on. Document them and keep them in one place. An essential ingredient should never become optional because the user raised leniency.
Allocate against one temporary stock ledger
The planner must reserve stock across the entire recipe. Otherwise two needs can spend the same product.
@dataclass(frozen=True)
class Allocation:
need_index: int
product_id: int
requested_amount: Decimal
grocy_amount: Decimal
substitution_path: tuple[str, ...]
fidelity: float
@dataclass(frozen=True)
class MealPlan:
recipe_id: str
status: str # ready, flexible, unavailable
allocations: tuple[Allocation, ...]
missing: tuple[str, ...]
missing_points: int
fidelity: float
For each need:
- Convert the recipe amount into the ingredient’s canonical unit.
- Find exact candidates, ordered by specificity and practical stock usage.
- Try ranked substitutions if exact stock cannot satisfy the need.
- Reserve the chosen quantities in a temporary ledger.
- Record missing points when an allowed nonessential need cannot be filled.
- Collapse allocations by product ID only after the plan is complete.
Make this engine a pure function of catalog, stock snapshot, options, and optional product selections. That keeps every edge case reproducible.
Fetch once with a data coordinator
Use one DataUpdateCoordinator for inventory fetching and shared planning.
class MealCoordinator(DataUpdateCoordinator[MealData]):
def __init__(self, hass, client, catalog, history, options):
super().__init__(
hass,
LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=options.scan_interval),
always_update=False,
)
self.client = client
self.catalog = catalog
self.history = history
self.options = options
async def _async_update_data(self) -> MealData:
stock = await self.client.async_get_stock()
plans = await self.hass.async_add_executor_job(
plan_all_recipes,
self.catalog,
stock,
self.options.leniency_points,
)
return build_meal_data(stock, plans, self.history)
Use always_update=False only when MealData implements useful equality. A coordinator update can produce:
- Ready full meals.
- Flexible full meals and their omissions.
- Ready components, such as a sauce or side.
- Unavailable meals.
- Aggregated shopping suggestions.
- Catalog metadata and ratings.
Store the coordinator in entry.runtime_data, perform async_config_entry_first_refresh(), and forward the entry to the sensor platform. Reload the entry when options change.
Re-plan immediately before deducting a meal
The plan on the dashboard is just a snapshot from the last poll. The consume_meal action must:
- Fetch live Grocy stock.
- Resolve the current recipe and servings.
- Re-plan with any product choices from the user.
- Reject an unavailable or overly compromised plan.
- Collapse allocations by Grocy product ID.
- Verify every live quantity before sending any write.
- Consume exact Grocy stock units.
- Record the meal and request a coordinator refresh.
Grocy deductions across several products are separate API calls, not a single atomic transaction. If a response is lost after one write, re-fetch live stock and reconcile before retrying. Never repeat the whole batch blindly.
Keep history and ratings in Home Assistant’s persistent Store. Store recipe IDs, timestamps, servings, plan fidelity, and summarized allocations - not secret connection data or unbounded API responses.
Expose small entities and validated actions
Useful sensors include:
sensor.meal_assistant_available_meals: count of ready meals.sensor.meal_assistant_planner: count of flexible or unavailable favorites.sensor.meal_assistant_recipe_catalog: catalog revision or recipe count.
Small catalogs can expose a compact summary in attributes. For a large recipe collection, use response actions or authenticated HTTP views instead of recording hundreds of recipes in Home Assistant’s state database.
Register actions in async_setup and describe them in services.yaml. Home Assistant’s current action-development guide explains schema registration and response data.
Suggested actions are:
meal_assistant.consume_meal:
recipe_id: quick-tomato-pasta
servings: 2
product_selections: {}
meal_assistant.get_recipe:
recipe_id: quick-tomato-pasta
meal_assistant.get_recipe_editor_options: {}
meal_assistant.upsert_recipe:
recipe: {}
meal_assistant.delete_recipe:
recipe_id: quick-tomato-pasta
meal_assistant.rate_meal:
recipe_id: quick-tomato-pasta
rating: 4
meal_assistant.refresh: {}
Use response-only actions for recipe details and editor choices. Their responses must be JSON-serializable dictionaries. Mutating actions should return a small confirmation only if the caller benefits from it.
Validate recipe edits through the exact same schema used at startup. Never let the frontend write arbitrary JSON directly into the active catalog.
Save custom recipes atomically
A useful default location is:
<config>/meal_assistant_recipes.json
Do not overwrite this file in place. A crash or full disk can truncate it. Use this sequence in a synchronous helper executed off the event loop:
- Read and validate the current file.
- Apply the requested change in memory.
- Validate the complete new catalog.
- Write a temporary file in the same directory.
- Flush and
fsyncit. - Preserve a backup of the previous valid file.
- Atomically replace the destination.
- Reload the catalog and coordinator.
Reject attempts to overwrite bundled recipe IDs unless you have designed an explicit override system. A typo should not silently replace a trusted recipe.
Add safe free-text inventory consumption
A separate action can handle requests such as “use one can of black beans” or “remove the rest of the spinach.” This is useful for snacks and ingredients consumed outside a saved recipe.
meal_assistant.consume_inventory_item:
query: black beans
amount: 1
unit: can
The resolver must be stricter than a search box because it writes inventory:
- Fetch current positive-stock products.
- Normalize punctuation, singular/plural forms, and a small documented alias map.
- Ignore command words and unit words.
- Score exact normalized names, aliases, token overlap, and canonical markers.
- Apply a minimum score and a minimum lead over the second candidate.
- Reject ambiguity and return a short candidate list.
- Convert the requested amount into Grocy units.
- Reject an overdraw before writing.
If the amount is omitted, decide on one clearly documented behavior. “Consume all current stock” can be convenient, but it is dangerous enough to require explicit confirmation in the UI or conversation layer.
Never silently choose between black beans, seasoned black beans, and black bean soup. Ask the user.
Add Home Assistant Assist only after the actions are safe
Voice and language-model access should call the same validated backend actions as the dashboard. Do not build a second deduction path.
There are two useful layers:
- A traditional intent handler for a constrained sentence such as “remove two cans of tomatoes.”
- An Assist LLM tool that accepts a product phrase, amount, and unit, then calls the strict resolver.
Home Assistant’s LLM interfaces continue to evolve. Follow the current LLM API documentation rather than copying an old class signature from a blog post. Keep the tool description narrow, require confirmation for destructive or “all stock” operations, and return ambiguity to the conversation instead of guessing.
The language model may interpret words. It should not select a Grocy product below your deterministic confidence threshold or bypass live quantity validation.
Build a local meal dashboard
Serve a JavaScript module such as:
/meal_assistant/meal-assistant-card.js
Register it as a module in Settings → Dashboards → Resources. Home Assistant’s custom resource guide covers current resource registration.
One module can provide:
customElements.define("meal-assistant-card", MealAssistantCard);
customElements.define("meal-assistant-planner-card", MealPlannerCard);
customElements.define("meal-assistant-catalog-card", RecipeCatalogCard);
Example card configuration:
type: custom:meal-assistant-card
title: What can we make?
show_flexible: true
confirm_before_consuming: true
The main card should show:
- Ready and flexible meals in separate groups.
- Missing or substituted ingredients before the user commits.
- Effort, time, servings, and fidelity.
- Product selection when more than one stock item can satisfy a need.
- A confirmation summary with exact planned deductions.
- A clear refresh state after consumption.
The planner card can aggregate missing ingredients across favorites. The catalog card can use response actions over Home Assistant’s WebSocket connection to fetch and edit recipes without turning the state machine into a document database.
Escape every catalog string before rendering it as HTML. Treat all frontend input as untrusted and repeat validation on the backend.
Set up and unload the integration cleanly
The core entry setup remains small:
PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass, entry):
client = GrocyClient(
async_get_clientsession(hass),
entry.data[CONF_URL],
entry.data[CONF_API_KEY],
)
catalog = await hass.async_add_executor_job(
load_catalog,
hass.config.path("meal_assistant_recipes.json"),
)
coordinator = MealCoordinator(hass, client, catalog, history, options(entry))
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
Register global actions and the static frontend path once in async_setup. Register entry-specific listeners during entry setup and remove them on unload. Unload every forwarded platform. If the integration contributes an intent or LLM tool, ensure reloads do not register duplicates.
Test policy, conversions, and failure behavior
The recipe happy path is the least interesting test. Concentrate on bad data and boundary cases.
Registry and recipe tests
- Duplicate and unknown ingredient IDs.
- Invalid regex, unit, importance, effort, minutes, or servings.
- Unsupported recipe format version.
- Duplicate ingredient within one recipe.
- Custom recipe collides with a bundled recipe.
- Component lacks completion ideas.
Classification and conversion tests
- Explicit markers beat inferred names.
- Exclusion patterns block false positives.
- Two equally plausible matches produce ambiguity, not a guess.
- Package count converts to the right canonical amount.
- Mass and volume are never mixed without an ingredient-specific rule.
- Decimal rounding never overdraws Grocy stock.
Engine tests
- Exact stock beats a substitution.
- Substitutions follow rank and direction.
- Two needs cannot allocate the same stock twice.
- Essential misses always make a recipe unavailable.
- Supporting misses use the documented point cost.
- Product selections are honored or rejected with a useful reason.
- Servings scale every allocation consistently.
Consumption tests
- Live re-planning catches stock changed since the last poll.
- Collapsed product deductions are correct.
- One failed request never triggers a blind batch retry.
- Free-text ambiguity returns candidates and performs no write.
- Missing amount, “all,” fractional packages, and overdraw follow explicit policy.
Home Assistant tests
- Config flow success, duplicate, cannot-connect, invalid-auth, and reauth.
- Coordinator timeout and malformed Grocy response.
- Action schemas and response payloads.
- Persistent recipe write, backup, reload, and recovery from an invalid file.
- Empty, loading, unavailable, and stale frontend states.
- Intent and LLM registration remains idempotent across reloads.
Use the Home Assistant Integration Quality Scale as a practical release checklist even for a personal custom component.
Install it manually before distributing it
- Copy
meal_assistantinto<config>/custom_components/. - Restart Home Assistant.
- Add Meal Assistant under Settings → Devices & services.
- Enter the local Grocy URL and API key.
- Add the frontend module under dashboard resources.
- Add the meal card to a test dashboard.
- Test planning with read-only data first.
- Use disposable Grocy products to verify consumption and failure recovery.
Compare the recipe requirement, normalized amount, displayed deduction, request sent to Grocy, and final live stock. All five should agree.
Package it for HACS
HACS expects the integration under custom_components/<domain> and currently permits one integration per repository.
If Meal Assistant and Alcohol Assistant share a development monorepo, create a separate release repository for each integration before HACS distribution. This also gives each project independent versions, releases, issues, and changelogs.
For default HACS inclusion, follow the current repository requirements, including public hosting, tagged GitHub releases, HACS validation, and hassfest. A personal custom repository can remain simpler, but tagged releases and clear upgrade notes are still worthwhile.
Common mistakes
- Using recipe text as the data model. Give every ingredient and recipe a stable canonical ID.
- Assuming one Grocy unit equals one recipe unit. Model package capacity and both conversion directions.
- Using loose substring matching. Add explicit markers, exclusions, confidence thresholds, and ambiguity margins.
- Treating every missing ingredient equally. Use importance and a documented leniency policy.
- Embedding substitutions in recipes. Keep one directional, ranked substitution catalog.
- Checking each ingredient without reserving stock. Plan against one temporary ledger.
- Trusting the last poll during consumption. Fetch and re-plan immediately before writes.
- Blindly retrying a failed batch. Reconcile against live Grocy stock first.
- Writing custom recipe JSON in place. Validate, back up, and atomically replace it.
- Letting voice or an LLM bypass validation. Every interface must call the same strict action path.
- Putting a large recipe catalog in entity attributes. Use small sensor state plus response actions or authenticated views.
- Shipping both assistants in one HACS repository. Publish one integration per repository.
Where to take it next
Once the fundamentals are trustworthy, useful extensions include expiration-aware ranking, leftovers as first-class products, household dietary profiles, preparation-time learning, weekly plan generation, barcode-assisted package setup, and an optional local model that proposes recipe drafts for human approval.
Keep those features above the deterministic boundary. AI can suggest that spinach and beans might work together; the registry, recipe validator, allocator, and Grocy client decide what the system can safely promise and deduct.
Get that boundary right and the Meal Assistant turns into something you actually cook from instead of another recipe demo.
If you want the bar version of the same idea, I built one of those too - see how to build a local Alcohol Assistant. And if you’d rather load Grocy by pointing a camera at your groceries and receipts than type everything in, that’s a separate job I handle with photo-intake AI skills.