chore: dissolve skill, content migrated to docs.fritzlab.net household/

Per decisions/2026-07-20-one-canon-knowledge-architecture; canon landed
via docs#142 (a0e98f75). Reference files deleted per the sites-household
deletions manifest; SKILL.md and README.md are now tombstones pointing at
https://docs.fritzlab.net/household/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 12:49:07 +00:00
parent 1a6ecd357f
commit 48373bed0e
6 changed files with 17 additions and 371 deletions
+4 -2
View File
@@ -1,3 +1,5 @@
# home
# home — dissolved
Home skill repo
This skill repo was dissolved per the 2026-07-20 one-canon knowledge
architecture decision. Household (Hawks Nest) knowledge lives at
https://docs.fritzlab.net/household/ — update the docs site, not this repo.
+13 -37
View File
@@ -1,45 +1,21 @@
---
name: home
description: >
Activate this skill for any work related to the Fritz household (Hawks Nest).
TRIGGER when: user mentions Hawks Nest, boat, MasterCraft, Minnetrista, Hawks Point,
home automation, Home Assistant, Lutron, Z-Wave, lights, automations, 4400 Hawks,
Mealie, meals, recipes, meal planning, meals.vino.network.
user-invocable: true
DISSOLVED. Do not use. Household (Hawks Nest) knowledge lives in the
canonical docs site: https://docs.fritzlab.net/household/
user-invocable: false
---
<quick-context>
- Address: 4400 Hawks Pt, Minnetrista, MN 55331
- Names: Home, The Hawks Nest
- Home Assistant: home.vino.network (HAOS on msp001 VM 123) — see fritzlab skill for access
- Mealie: https://meals.vino.network (sjc001 cluster, `mealie` namespace, group `Home` / household `Family`)
</quick-context>
# home — dissolved
<safety>
- Do not disable active automations without understanding their dependencies first.
</safety>
This skill was dissolved per the one-canon knowledge architecture decision
(decisions/2026-07-20-one-canon-knowledge-architecture on docs.fritzlab.net).
All content migrated to the canon in docs#142 (a0e98f75):
<style>
- Keep references low token count, organized with XML tags.
- State what IS, not what happened.
- MUST keep this skill up to date. Knowledge lives HERE, not in memory.
</style>
- https://docs.fritzlab.net/household/ — landing page
- reference/home-assistant.md → household/home-assistant.md, household/home-assistant-app.md, household/home-assistant-av.md
- reference/boat.md → household/boat.md
- reference/kiosk.md → household/kiosk.md
- reference/mealie.md → household/mealie.md
<references>
<reference file="reference/home-assistant.md"
summary="HA app layer on home.vino.network: Nest Matter naming and cloud-fallback target, storage-mode Lovelace editing, whole-house HDMI matrix integration, Great Room AV topology and Denon recovery, set_mode scripts, and virtual-remote subviews. Infra/access owned by fritzlab skill."
categories=["services", "automation", "display"]
keywords=["Home Assistant", "Lovelace", "dashboard", "virtual remote", "TV remote", "great room", "Frame TV", "HDMI matrix", "hdmi_matrix", "NHAV-1632", "HDBaseT", "send_command", "Denon AVR", "Roku", "Fire TV", "set_mode", "home.vino.network", "subview", "media_player.great_room"] />
<reference file="reference/boat.md"
summary="Boat details: Stranger Fins, 2025 MasterCraft 21 — specs and info"
categories=["boat", "fun", "entertainment"]
keywords=["MasterCraft", "NXT", "boat", "Stranger Fins", "wake", "surf"] />
<reference file="reference/mealie.md"
summary="Mealie REST API: add/edit recipes, ingredient parser (food/unit id resolution + locale seeding), tag/category and instruction gotchas, misleading-error decoder"
categories=["food", "services"]
keywords=["Mealie", "meals.vino.network", "recipe", "recipes", "meal", "meal planning", "ingredient parser", "PATCH recipe", "MEALIE_TOKEN", "household Family"] />
<reference file="reference/kiosk.md"
summary="Wall-display kiosks on host101/host102 (Chromium full-screen). Both default to kiosk.vino.network (static debug page in websites/kiosk.vino.network). 'Update the kiosk page' = edit that repo's html/ + push. Changing the URL = KIOSK_URL via GitOps + hard-refresh."
categories=["services", "display"]
keywords=["kiosk", "update the kiosk page", "kiosk.vino.network", "screen", "display", "wall panel", "TV", "monitor", "webpage", "Chromium", "KIOSK_URL", "host101", "host102", "signage", "dashboard"] />
</references>
Do not add knowledge here. Update https://docs.fritzlab.net instead.
-11
View File
@@ -1,11 +0,0 @@
---
name: boat
description: Boat Information
---
<boat
name='Stranger Fins'
model='2025 MasterCraft 21'
/>
-92
View File
@@ -1,92 +0,0 @@
# Home Assistant (app layer)
<scope>Dashboards (Lovelace), the Great Room AV stack, and household automations on `home.vino.network`. Access/SSH/API/Z-Wave infra is owned by the **fritzlab** skill [[home-assistant]] (`reference/home-assistant.md`). Token: `VINO_HA_TOKEN` in `~/.env`.</scope>
<config-repo>
Config is version-controlled at **`dfritz/home-assistant`** (code.fritzlab.net; clone `~/code/git/code.fritzlab.net/dfritz/home-assistant`): a manual mirror of the safe parts of HAOS `/config` — YAML config + includes, blueprints, `custom_components/frame_art`, `www/` cards, and a `dashboards/` snapshot of the storage-mode Lovelace (export, not live-sync). Gitignored: `secrets.yaml`, `.storage/`, the DB, `*.pem`, logs, deps/venv. Externally-managed components stay in their own repos: celebright → `dfritz/celebright`, hdmi_matrix → `dfritz/haas-hdmi-matrix-custom-component`. No CI auto-deploy — sync via `rsync -e "ssh -6"` to/from `home-assistant.vino.network:/config/`.
</config-repo>
<nest-matter>
## Nest thermostats
Matter Server 9.1.0 and the `matter` integration provide local control for three
Nest Learning Thermostat 4th gen nodes plus their bridged sensors. Primary devices
are `Entryway Nest`, `Master Bedroom Nest`, and `Sport Court Nest`; climates are
`climate.entryway_nest`, `climate.master_bedroom_nest`, and
`climate.sport_court_nest`. Thermostats carry `Matter Nest` + `Matter HVAC` labels;
child devices are `<Location> Sensor Nest` and unlabeled. All three are forced-air
`heat_cool` zones.
The cloud `nest` fallback integration is not configured. It requires a dedicated
Vino Web OAuth client in Google Cloud project `home-assistant-471823` (redirect
`https://my.home-assistant.io/redirect/oauth`) and a separate Device Access project;
do not reuse Lodge OAuth credentials, Device Access project, or Pub/Sub subscription.
Target cloud naming is `<Zone> Google Nest` and `climate.<zone>_google_nest`, labels
`Google Nest` + `HVAC`.
Recovery backup `pre-nest-normalization-2026-07-18` (`a7b09a9b`) contains HA
registry/dashboard state without the database.
</nest-matter>
<lovelace>
## Editing dashboards (Lovelace)
Both dashboards (`Overview` url_path `lovelace`, `Map`) are **storage mode** (UI-managed), not YAML — no file to edit. Drive over the WS API:
- Read: `{"type":"lovelace/config"}` (no `url_path` = default `Overview`) → full config (`views[]`).
- Write: `{"type":"lovelace/config/save","config":<whole config>}`**whole-config read-modify-write**, not per-view. Always back up the fetched config first; a bad save replaces every view.
- Views use `subview: true` for the per-device remotes (reached by nav, not the top tab bar). Card commands: copy a *proven* button from a working view rather than guessing service/command strings.
- No HACS, no custom themes (`frontend/get_themes` empty). Custom Lovelace cards: drop the JS in `/config/www/` (root-owned → `ssh -6 … sudo tee`), register via `{"type":"lovelace/resources/create","res_type":"module","url":"/local/<file>.js?v=N"}`. Served at `https://home.vino.network/local/<file>.js`. Browser caches resources — bump `?v=` and hard-refresh (mobile app: restart) to pick up changes.
</lovelace>
<great-room-av>
## Great Room TV / AV topology
TV = **Samsung Frame** (`media_player.the_frame_tv` power+vol+source `['TV','HDMI']`; `remote.the_frame_tv` needs Samsung `KEY_*` codes — see <great-room-remote-card>). WoL power-on MAC `C8:A6:EF:AE:E5:FA` via `script.turn_on_great_room_tv_2`.
Sources feed an **HDMI matrix** (see <hdmi-matrix>); `select.hdmi_matrix_vino_network_output_1` (named "Great Room TV") = current TV input, `..._output_3` = speakers. Options incl. `Roku-Ultra`, `Roku-3`, `FireTvStick`, `From-GR-AVR`, `Kiosk1/2`.
**Matrix-source audio is the Denon AVR**, not the TV: zones `media_player.great_room` (+ `media_player.kitchen`), source `HDMI Matrix`; volume = `media_player.great_room`, and the TV's own speaker is set near-mute by the building block. TV-direct audio = the TV itself (the remote card's volume is context-aware — see <great-room-remote-card>).
Source switching = `script.set_mode_great_room_*`:
- `movie` → Roku Ultra (alias `set-mode-great-room-roku-ultra`), `roku_3`, `fire_tv_stick`, `music`, `camera_scanner`.
- Each sets both matrix outputs then runs `script.building_block_set_great_room_to_hdmi_matrix` (TV→HDMI, AVR zones on, vol 0.55, ALL ZONE STEREO).
Source devices: Roku (`remote.roku_ultra`/`roku_3`, commands **lowercase** `home/back/up/down/left/right/select/info`); Fire TV (`remote.fire_tv_stick`, commands **UPPERCASE** `HOME/BACK/UP/DOWN/LEFT/RIGHT/CENTER/INFO/MENU`). App shortcuts via `media_player.select_source` (Roku Plex source = `Plex - Free Movies & TV`).
Great Room AVR = Denon AVR-X2700H. `setup_error` plus a timeout on
`:8080/goform/Deviceinfo.xml` is a wedged receiver network stack; the narrow repair
is Denon telnet command `NSRBT`, wait for Deviceinfo HTTP 200, then reload the
`Great Room` config entry. This preserves power/mute state and restores
`media_player.great_room` + `media_player.kitchen`.
</great-room-av>
<hdmi-matrix>
## HDMI matrix (whole-house AV routing)
No Hassle AV **NHAV-1632** 16×32 HDMI/HDBaseT matrix at `hdmi-matrix.vino.network` (172.24.15.50, IPv4-only); the `admin/admin` web login is cosmetic — no auth on any endpoint, the LAN is the boundary. Drives every house display (Living Room, Office, bedrooms, Terrace, Great Room, speakers) through 16 output pairs (HDBaseT run + mirrored local HDMI).
Integration `hdmi_matrix` (repo `dfritz/haas-hdmi-matrix-custom-component`, its own repo — gitignored from the config mirror) is built on the device's binary state protocol. One HA device keyed on the matrix MAC:
- `select.hdmi_matrix_vino_network_output_1..16` — source per output (**enabled**); friendly names track the device's live port names.
- `switch.…_output_N_stream` (blank one output's video) + `binary_sensor.…_input_N_signal` (live source detect) — **disabled by default**, enable as needed.
- `hdmi_matrix.send_command` service — raw `!`/`#` device command (wire numbering is 0-based).
Coordinator delta-polls every 2 s (idle poll ~32 B); front-panel/IR/web changes converge within a poll. Full protocol + a stdlib decoder (`tools/dump_state.py`) live in the repo's `docs/device-api.md` — not restated here.
**Deploy:** rsync `custom_components/hdmi_matrix/``/config/custom_components/hdmi_matrix/`, then a full `ha core restart` (a config-entry reload does NOT re-import changed Python; `ha` CLI needs the login shell for `SUPERVISOR_TOKEN`, sudo drops it). NEVER stage a backup dir inside `custom_components/` — HA scans every subdir, and a dotted name (`hdmi_matrix.bak`) plus its `manifest.json` shadows the `hdmi_matrix` domain and breaks import.
**Physical (rear panel):** a port LED lights only when its TX drives live video into an OPEN, unlinked port; a linked receiver or a no-signal input reads dark; blinking = link-training failure. The RemoteTV EDID slots are a last-seen sink cache, not live state — the matrix exposes NO live HDBaseT link status anywhere. Known-bad: matrix **port 1 (Output1, the Great Room TV feed) HDBaseT transmitter is suspect-dead** — its config is identical to working ports yet it stays dark through stream-toggle, source re-switch, standby cycle, and reboot; migrate that run to a spare output (1216 are proven-good) if it stays dark.
</hdmi-matrix>
<remotes>
## Virtual remotes (Lovelace subviews, Overview dashboard)
- `great-room-tv-remote`**the main one.** A bespoke custom card `custom:great-room-remote-card`: plus-shaped D-pad, context-aware volume, source pills (set_mode scripts), apps/playback, power toggle. Fits an iPhone with no scrolling; details in <great-room-remote-card>.
- `roku-ultra-remote`, `roku-3-remote`, `fire-tv-stick-remote` — older stock single-device remotes (still present).
URL: `https://home.vino.network/lovelace/great-room-tv-remote`. Launcher: a "Great Room TV" navigate-button under the **Virtual Remotes** heading on the `kitchen_greatroom` view (subviews aren't in the tab bar, so they need a navigate-button entry point).
<great-room-remote-card>
Source-of-truth: `dfritz/home-assistant` repo → `www/great-room-remote-card.js` (vanilla custom element, no build step). Deployed copy → HA `/config/www/great-room-remote-card.js`, resource `/local/great-room-remote-card.js?v=N`. Robust layout: plus-shaped CSS-grid D-pad (no abspos) with Back/Home/Info column beside it, wrapping grids, single delegated click handler. Fits an iPhone with no scroll (~630px, ≤660 budget); apps row collapses in TV mode.
**Control target is explicit + visible** (a "Controlling: X" line + a `[Follow source] | [TV]` toggle):
- *Follow source* (default): D-pad/transport/apps drive the device the matrix is on (Roku `select`/lowercase, Fire TV `CENTER`/UPPERCASE). Falls back to TV when the matrix state isn't a known source (e.g. `Viewport16`).
- *TV*: forces `remote.the_frame_tv`. One-tap fallback if a source device (asleep Roku → "Error communicating with Roku API") doesn't respond. **Samsung `samsungtv` integration needs `KEY_*` codes** (`KEY_UP/DOWN/LEFT/RIGHT/ENTER/RETURN/HOME/INFO`, `KEY_VOLUP/VOLDOWN`) — lowercase words return HTTP 200 but the TV silently ignores them (this is why the original lowercase remote never actually drove the TV).
- Tapping a source pill switches the matrix (`set_mode` script) and snaps target back to Follow source.
**Volume/mute are context-aware too** (per-profile `volume` entity): TV target → `media_player.the_frame_tv` (Samsung supports `volume_set`); source target → `media_player.great_room` (Denon AVR zone, the audio path for matrix sources). The slider uses a timestamp guard (not `activeElement`) so frequent `hass` updates don't snap the thumb back mid-drag on iOS. Frame TV can't launch apps via `select_source` (source_list only `['TV','HDMI']`), so apps only show for Roku/Fire. All entities/commands live in `DEFAULTS` (config-overridable).
**Deploy after edit:** `node --check` then `cat www/great-room-remote-card.js | ssh -6 home-assistant.vino.network 'sudo tee /config/www/great-room-remote-card.js'`, bump resource `?v=` (WS `lovelace/resources/update`, resource_id `7ee4fcac14ea4a3b9ef76c0625a89721`), hard-refresh.
</great-room-remote-card>
</remotes>
-85
View File
@@ -1,85 +0,0 @@
# Kiosks (wall displays)
<what>Full-screen web kiosks on the two Nest compute nodes, each driving its attached display (DP/HDMI) via Chromium. Run as Kubernetes pods on the msp001 cluster (the Nest site). Infra/build/troubleshooting: see fritzlab skill `msp001.md` <kiosk> + the `fritzlab/kiosk` repo.</what>
<screens>
| Node | Pod (ns `kiosk`) | Output | Showing |
|---|---|---|---|
| host101 | `kiosk-host101` | card0-DP-1 (4K) | kiosk.vino.network |
| host102 | `kiosk-host102` | card0-DP-1 (4K) | kiosk.vino.network |
Each node drives one screen; multi-screen would need host100's GT 730 (see KIOSK-DESIGN.md).
</screens>
<resolution>
Design target = **3840×2160 (4K UHD), devicePixelRatio 1, 24-bit color,
landscape-primary**. Both displays render at native 4K; the Chromium kiosk
has no toolbar/taskbar, so viewport == screen == avail.screen == 3840×2160
(no CSS px scaling — 1 CSS px = 1 device px). Layout for the kiosk page should
look right at full 4K landscape. (Confirmed on-screen 2026-06-01.)
</resolution>
<the-kiosk-page>
The default page both screens show is `https://kiosk.vino.network` — an
editorial wall-art homepage: a **live iOS-Weather-style animated sky** +
live local-time clock + current weather from `api.open-meteo.com`. The
sky is driven by the Open-Meteo `weather_code` (+ `wind_speed`) +
`sunrise`/`sunset`: sun (clear/partly days) and moon (clear/partly nights)
**arc across the sky by real time-of-day**, with a warm dawn/dusk horizon
glow. Conditions rendered: clear, partly, cloudy, fog, drizzle, rain,
**sleet/freezing-rain (ice)**, snow (**blizzard/blowing-snow when wind
≥24 mph**), thunderstorm (**hail on codes 96/99**), plus stars on clear
nights and lightning in storms. The **moon mirrors its
actual phase** (lit fraction + waxing/waning terminator, computed locally
from the date — NOT an extra API call). Weather fetches on load then every
10m, with **fast retry-with-backoff (15s→5m) while failing** so a fetch that
fails at pod start doesn't sit on "Weather unavailable" for the full 10m
(fixed 2026-06-04; live weather scene 2026-06-04). A frozen clock/stale weather on one screen =
that node's Chromium (or the whole node) is wedged, NOT a page bug — the
display holds the last-good frame; recover by graceful pod restart (and see
fritzlab `msp001.md` <kiosk> if the node itself is unreachable).
A **live** clock but "Weather unavailable" + a stale day/night scene (night
shown in daytime) is the opposite case — NOT Chromium: the pod can't reach
`api.open-meteo.com`, so `wx()` fails and `SUN` (sunrise/sunset) freezes at
its last-good values → wrong palette. Root cause is usually msp001 pod
egress/DNS loss from a UDM BGP wipe (now auto-healed) — see fritzlab
`msp001.md` <bgp>. Confirm with `kubectl --context msp001 exec -n kiosk
<pod> -- curl -sm10 https://api.open-meteo.com/v1/forecast?...` from the pod.
Source repo: `websites/kiosk.vino.network` (clone at `~/code/git/code.fritzlab.net/websites/kiosk.vino.network/`),
content in `html/`, served from Garage S3 via the standard site-publish flow
(fritzlab `gitops.md` <static-sites>).
<per-house-config>Location + house name are **query params on `KIOSK_URL`**, so
one page serves any household: `?zip=<us-zip>&name=<House%20Name>`. `name` sets
the eyebrow + tab title; `zip` is geocoded once at load (Zippopotam.us) → lat/lon
+ "City, State" place line, and Open-Meteo runs `timezone=auto` so the clock
follows that zip's timezone. No params → Hawks Nest / Minnetrista 55331 /
`America/Chicago` defaults (bad zip falls back to these too). To point a screen
at a different house, set `KIOSK_URL=https://kiosk.vino.network/?zip=...&name=...`
per pod via GitOps (see <change-page> for the apps-repo edit + hard-refresh).</per-house-config>
**"Update the kiosk page" = edit the page content**, NOT the KIOSK_URL: edit
`html/index.html` (or `404.html`) in that repo, commit + push to main → the
`Publish` Gitea Action (site-publish) syncs to the bucket in ~30s. Verify with
`curl -s https://kiosk.vino.network/`.
**How screens actually pick up a new deploy:** the Chromium kiosk fetches the
URL ONCE at pod start and never re-polls on its own (`entrypoint.sh` only
re-fetches if cage/chromium exits or the pod restarts) — `Cache-Control:
must-revalidate` alone does NOT update a running screen. So the PAGE must
self-reload. `index.html` polls its own ETag every 5m and `location.reload()`s
only when it changes (no periodic flashing). Any new kiosk page MUST carry that
ETag-poll snippet, else the screens stay on the old content until a pod restart.
</the-kiosk-page>
<change-page>
This changes WHICH URL a screen loads (different site), distinct from updating
the kiosk page content above. The displayed URL is the `KIOSK_URL` env per pod.
**GitOps (only reliable way):** edit `KIOSK_URL` in `fritzlab/apps/msp001/kiosk/kiosk/manifests/deployments.yaml` (block `kiosk-host101` or `kiosk-host102`), commit + push to `apps` main, then force the sync now instead of waiting ~3min for the poll: `kubectl --context msp001 -n argocd annotate application kiosk --overwrite argocd.argoproj.io/refresh=hard`. Pod recreates on the new page.
**Do NOT use `kubectl set env` for this:** ArgoCD selfHeal reverts it to the *stale* git target (the value from the last commit it polled, not your new one) within seconds — the pod recreates but on the old URL. Push to git + hard-refresh instead. (Verified 2026-06-01.)
</change-page>
<verify>`kubectl --context msp001 get pods -n kiosk` (want 1/1 Running). On-screen page check needs a screenshot via Chromium CDP (localhost-only) — see fritzlab `msp001.md` <kiosk>. Blank-screen causes: (1) cage logs `Swapchain for output ... failed test` on repeat → dirty DRM/GBM state from an UNCLEAN prior exit; (2) `getty@tty1` re-took DRM master (re-mask on the node); (3) page down (Chromium holds last-good frame).</verify>
<restart>**Restart kiosk pods GRACEFULLY — never `--force`/`--grace-period=0`.** A hard kill stops cage from dropping DRM master, leaving the next cage with a dirty swapchain → black. Use `kubectl --context msp001 delete pod -n kiosk -l node=host101` (or `rollout restart deploy -n kiosk`). The image self-heals a wedged swapchain in-pod (entrypoint kills+relaunches cage after 5 swapchain failures) and a `tcpSocket:9222` liveness probe restarts a dead Chromium — but a clean shutdown is still the first line of defense. Recovery for an already-black screen = one graceful delete. (Diagnosed + hardened 2026-06-01.)</restart>
<other-options>Want a dashboard, photo slideshow, or Home Assistant wall panel instead of a website — just point `KIOSK_URL` at it (e.g. `https://home.vino.network`). [[home-assistant]] for HA URLs.</other-options>
-144
View File
@@ -1,144 +0,0 @@
---
name: mealie
description: Adding and editing recipes in the family Mealie instance, including the non-obvious ingredient-parsing requirements
---
<instance>
- URL: https://meals.vino.network (Mealie v3.18.x, OIDC via Authentik)
- Hosted in sjc001 cluster, `mealie` namespace
- Family group/household: `Home` / `Family`
- API token: stored in `code/git/code.fritzlab.net/fritzlab/agent/.env` as
`MEALIE_TOKEN` (paired with `MEALIE_URL=https://meals.vino.network`).
Regenerate at https://meals.vino.network/user/profile/api-tokens.
- All endpoints take `Authorization: Bearer $MEALIE_TOKEN`.
</instance>
<conventions>
- **Every recipe must carry exactly one meal-type tag**: `Breakfast`, `Lunch`, `Dinner`,
or `Dessert`. When importing or editing a recipe, add the appropriate one; preserve
any other tags already on the recipe. If meal type is ambiguous (e.g. banana bread →
breakfast or dessert), ask the user — don't pick silently.
- Meal-type tag IDs in the family Mealie:
- `Breakfast` = `38fb71bf-a89d-45ee-9d21-0b1e5f716569`
- `Lunch` = `5f0ce972-2243-4ef6-b3c7-ad24f8561e84`
- `Dinner` = `bc992ba9-80ce-4407-8d1e-e69a7f659ebb`
- `Dessert` = `e7adc953-3f23-4032-804b-ada18b3a2da9`
- To append a tag without clobbering: GET the recipe, take `tags`, append the new
`{id, name, slug}` object, PATCH back with `{"name": <name>, "tags": [...]}`.
</conventions>
<auth-test>
GET /api/users/self — confirms the token works and returns group/household.
</auth-test>
<add-from-url>
Mealie scrapes recipe sites that publish schema.org Recipe JSON-LD.
POST /api/recipes/create/html-or-json with `{"url":"https://..."}` — returns the new slug.
Falls back to scraping HTML if no JSON-LD. PDFs and image URLs do NOT work here.
</add-from-url>
<add-from-raw>
Two-step: create then patch. Mealie has no single endpoint that takes a full recipe at once.
1. POST /api/recipes body `{"name": "..."}`
Returns the slug as a **raw JSON string** (e.g. `"my-recipe"`), not an object.
If a recipe with the same name exists, Mealie auto-suffixes (`-1`, `-2`). To avoid
duplicates, GET /api/recipes first and match by name before posting.
2. PATCH /api/recipes/{slug} with the full Recipe-Input body:
- `name`, `description`, `prepTime`, `cookTime`, `totalTime` (free strings)
- `recipeYield` (string), `recipeServings` (number), `recipeYieldQuantity` (number)
- `recipeIngredient: [...]` — see ingredient-parsing below
- `recipeInstructions: [{text, ingredientReferences: []}, ...]`
- `tags: [{id, name, slug}, ...]`, `recipeCategory: [{id, name, slug}, ...]`
- `notes: [{title, text}, ...]`
- `orgURL: "..."` — source link
- DO NOT include `slug` in the PATCH body; it triggers a rename attempt that conflicts
with the current slug and returns 400 "Recipe already exists".
</add-from-raw>
<edit-existing>
Same as step 2 above: PATCH /api/recipes/{slug} with the fields to change. Other fields
are preserved. To replace the entire ingredient list, send the full new `recipeIngredient`
array. To clear a list, send `[]`.
DELETE /api/recipes/{slug} removes the recipe.
</edit-existing>
<ingredient-parsing>
Mealie has an NLP parser (also `brute` and `openai` variants) that splits raw strings like
`"1½ tablespoons mayo"` into structured `{quantity, unit, food, note}` objects. The unit
and food are objects with their own `id` referencing rows in `/api/units` and `/api/foods`.
POST /api/parser/ingredients
body: `{"parser":"nlp", "ingredients":["1½ tablespoons mayo", "4 ears fresh corn, husked", ...]}`
returns one ParsedIngredient per input.
Parser gotchas that bite every time:
1. **Unit / food objects with `id: null` cannot be PATCHed onto a recipe** — server raises
`ValueError: Expected 'id' to be provided for unit/food`. The parser returns `id: null`
whenever the unit/food name isn't already in the database. Workflow: after parsing,
resolve each name against `/api/units` and `/api/foods` (auto-creating missing ones via
POST), then set `ing.unit.id` and `ing.food.id` before PATCHing the recipe.
2. **Seed the locale before parsing** or the parser fuzzy-matches against whatever you
created previously. Concrete bite: with only `tablespoon` in the unit table, every
"1 teaspoon" parses to `unit_id = <tablespoon's id>` because the Levenshtein distance
is 1. Pre-seed once per instance:
- POST /api/groups/seeders/units `{"locale":"en-US"}`
- POST /api/groups/seeders/foods `{"locale":"en-US"}`
These populate the standard US units (cup, teaspoon, tablespoon, ounce, etc.) and
common foods. After seeding the parser stops conflating teaspoon/tablespoon. Already
done on the family Mealie 2026-05-22.
3. **Sections in an ingredient list** (e.g. "Cookie dough" vs "Buttercream" in the same
recipe): set `title` on the FIRST ingredient of each section. Convention used in the
family import script: prefix the raw input with `[Section Name]`, the helper strips
the bracket and promotes it to `title` on the first ingredient of that group.
4. **Instructions also have a hidden required field**: each step object must include
`ingredientReferences: []`. The OpenAPI schema says it defaults to `[]`, but PATCH's
`model_dump(exclude_unset=True)` excludes it and the SQLAlchemy model then raises
`RecipeInstruction.__init__() missing 1 required positional argument: 'ingredient_references'`.
Always send `ingredientReferences: []` explicitly.
</ingredient-parsing>
<tags-categories>
RecipeTag and RecipeCategory objects in a PATCH body need **all three** of `id`, `name`,
`slug`. Sending just name+slug produces a misleading 400 "Recipe already exists" (the
real cause is a SQL integrity error logged server-side as `SQL Integrity Error on recipe
controller action`).
Create with POST /api/organizers/tags or POST /api/organizers/categories — body
`{"name":"..."}` — response contains the `id` and `slug` to reuse.
Existing IDs in the family Mealie:
- Tag `Mexican Street Eats Class` = `bb6841c5-3b6d-4569-b707-3d14bea83d7b`
- Category `Mexican` = `6ee64be6-83ce-4d6e-926c-62edfb791a41`
</tags-categories>
<misleading-errors>
- `400 "Recipe already exists"` on PATCH → almost always means tag/category/food/unit
is missing an `id`. Check the server logs (`kubectl --context sjc001 -n mealie logs
-l app=mealie`) for `SQL Integrity Error` to confirm.
- `500 TypeError` on PATCH → missing `ingredientReferences: []` on an instruction.
- `500 ValueError: Expected 'id' to be provided for unit` → parsed ingredient still has
`unit.id: null` or `food.id: null`; resolve via /api/units, /api/foods first.
- `500` on POST /api/foods or /api/units when the name already exists → list and dedupe
before creating, or catch the UniqueViolation.
</misleading-errors>
<reusable-import-script>
A working ingest script for the Mexican Street Eats PDF lives at
`/tmp/add_mealie_recipes.py` during the 2026-05-22 session. Pattern to reuse:
1. Build food + unit name→id caches once (GET /api/foods, /api/units, paginate).
2. For each raw ingredient line, POST to /api/parser/ingredients.
3. For each parsed result with `unit.id: null` or `food.id: null`, look up by lowercased
name in the cache; POST to create if missing; update cache.
4. For sectioned recipes, strip `[Section]` prefix and set `title` on first item.
5. Build PATCH body with `recipeIngredient`, `recipeInstructions` (each with
`ingredientReferences: []`), `tags` and `recipeCategory` (with ids).
6. POST to /api/recipes for the name, then PATCH /api/recipes/{slug} with the body.
</reusable-import-script>