All checks were successful
Deploy / deploy (push) Successful in 23s
The fix itself lives entirely in HA config (automation.lunch_menu_family_calendar), not in this repo, so there's nothing to redeploy here -- this just records what broke and why, for the next time someone (human or Claude) is debugging why calendar events aren't showing up. Root cause: mode: single (HA's default) let only one webhook-triggered run execute at a time; a week push fires 5 POSTs back-to-back, so 4 of 5 got rejected as failed_single and only one day's event was ever created. Switched to mode: parallel and verified all 5 days land correctly now. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KsJqAZ82Veyf5oihBr71S5
135 lines
7.9 KiB
Markdown
135 lines
7.9 KiB
Markdown
# Lunch Notifier
|
|
|
|
Single-file Flask app that scrapes the weekly Mariposa (Kyrene school
|
|
district) lunch menu and pushes each day to a Home Assistant calendar as an
|
|
all-day event. Runs as one Docker container on the user's Unraid home
|
|
server.
|
|
|
|
**2026-09-03: pivoted from SMS to calendar-only.** Originally texted phone
|
|
numbers via email-to-SMS carrier gateways; that whole path (Gmail SMTP,
|
|
phone number management, carrier gateway settings) was deleted at the
|
|
user's request in favor of Home Assistant calendar events exclusively. If
|
|
you find references to texting/SMS/phone numbers anywhere (docs, old
|
|
commits), they describe the pre-2026-09-03 app, not the current one.
|
|
|
|
## Architecture
|
|
|
|
- **`app.py`** — everything: Flask web UI/API, `schedule`-library job
|
|
scheduling, and the Home Assistant webhook push. The HTML/CSS/JS frontend
|
|
is embedded as a Python string constant (`HTML`) rather than a separate
|
|
template file — no build step, no template engine.
|
|
- **`scraper.py`** — fetches and parses the school's published lunch menu
|
|
from Nutrislice's API. `get_week_menu(date_str)` returns every day in the
|
|
API's week-granular response as `[{"date": ..., "items": [...]}, ...]`
|
|
(the API returns a full week regardless of which single date you ask
|
|
for). Days with no posted menu (weekends, breaks, holidays) come back
|
|
with an empty `items` list rather than being omitted.
|
|
- Settings (calendar pull schedule) persist to `/data/settings.json`, not
|
|
environment variables — env vars (`CALENDAR_RUN_TIME`, `CALENDAR_RUN_DAYS`)
|
|
are defaults used only the very first time, before a settings file exists.
|
|
`load_settings()` merges the file's contents *over* `_DEFAULTS` rather
|
|
than returning the file verbatim — this matters because the server's
|
|
settings.json predates this schema (it's from the SMS era) and lacks
|
|
`calendar_run_time`/`calendar_run_days` entirely; without the merge, the
|
|
scheduler would silently come up with zero jobs after every deploy until
|
|
someone happened to re-save the Schedule card in the UI. Verified via a
|
|
migration test (old-schema settings.json + fresh container → correct
|
|
defaults filled in, `next_calendar_run` populated).
|
|
- **No vacation/no-push date ranges (removed 2026-09-03, briefly existed).**
|
|
A day with no posted menu already produces an empty `items` list from
|
|
`get_week_menu()`, and `_push_calendar()` already skips pushing when
|
|
`items` is empty — that's sufficient for "don't create an event for a day
|
|
with nothing happening." A separate vacation-range setting to *additionally*
|
|
suppress days the school did post a menu for was judged unnecessary
|
|
complexity and deleted at the user's request; don't re-add it without
|
|
being asked.
|
|
- **Home Assistant integration (built 2026-09-02/03, calendar-only pivot
|
|
2026-09-03):** `HA_WEBHOOK_URL` is now required, not optional — it's the
|
|
only way this app does anything externally-visible. On the HA side
|
|
(`automation.lunch_menu_family_calendar`, `http://192.168.86.207:8123`),
|
|
a `local_only` webhook trigger runs `calendar.create_event` against
|
|
`calendar.family_2` (the "Family" Google Calendar entity — there are two
|
|
entities both named "Family" in this HA instance, `calendar.family` and
|
|
`calendar.family_2`; this integration deliberately targets `_2` per user
|
|
choice). `calendar.family_2` only supports `create_event`, not
|
|
`delete_event` — a bad push can't be cleaned up via the HA API, only
|
|
manually in Google Calendar. No duplicate-prevention: re-pushing the
|
|
same date's menu creates a second event — this is why the default
|
|
schedule is a single day/week, not daily (see below).
|
|
**`mode: parallel`, not the default `single` (fixed 2026-09-03 after a
|
|
real bug):** a week push fires one webhook POST per school day
|
|
back-to-back with no delay. Under `mode: single`, HA rejects any
|
|
webhook trigger that arrives while a previous run is still executing
|
|
(`calendar.create_event` itself takes ~600-700ms) — 4 of every 5 days
|
|
were silently dropped as `failed_single`, so only one day's event ever
|
|
actually got created per week push. Found via `ha_get_automation_traces`
|
|
after the user reported a manual test push not showing up on the
|
|
calendar. Each invocation is independent (one `calendar.create_event`
|
|
call, no shared state), so `parallel` is the correct mode per HA's own
|
|
guidance ("independent per-entity actions → parallel"), not just a
|
|
workaround. The webhook id is
|
|
a random token that lives only in the server's `.env` (`HA_WEBHOOK_URL`)
|
|
and in the automation's trigger config — never committed here. If the
|
|
calendar push stops working, check both ends: `GET /api/integrations`
|
|
on this app reports whether `HA_WEBHOOK_URL` is set at all, and
|
|
`ha_get_automation_traces` on `automation.lunch_menu_family_calendar`
|
|
shows whether HA is even receiving the webhook.
|
|
- **Independent weekly-pull scheduler (2026-09-03):** calendar pulls run on
|
|
their own schedule (`calendar_run_time`/`calendar_run_days` in settings,
|
|
`schedule` jobs tagged `"calendar"`), completely decoupled from any other
|
|
job — there's nothing else in this app anymore, but the tag/isolation
|
|
pattern (`schedule.clear("calendar")` instead of a bare `schedule.clear()`)
|
|
is deliberate so a future second schedule wouldn't stomp this one.
|
|
`push_week_to_calendar()` fetches the whole current school week and
|
|
pushes every day with a posted menu. Default is Sunday 18:00 (one
|
|
firing/week) — the UI warns that picking more than one day/week creates
|
|
duplicate events for days already covered, since there's no dedup.
|
|
- **Manual actions:** "Push This Week to Calendar" (`POST /api/push-calendar`
|
|
→ `push_week_to_calendar()`, same function the schedule calls) and
|
|
"Preview This Week's Menu" (`GET /api/preview` → `get_week_menu()`
|
|
directly, pure dry run, no Home Assistant push, no side effects at all —
|
|
use this to check the scraper before trusting a scheduled push).
|
|
|
|
## Local development
|
|
|
|
```bash
|
|
pip install -r requirements.txt
|
|
python3 app.py
|
|
```
|
|
|
|
Needs `HA_WEBHOOK_URL` set to actually push anywhere; without it the app
|
|
still runs and previews fine, `GET /api/integrations` just reports
|
|
`ha_webhook_configured: false` and pushes resolve to `"not configured"`.
|
|
Visit `http://localhost:2323`.
|
|
|
|
## Deployment
|
|
|
|
**Target:** Unraid server, container path `/mnt/user/appdata/lunch-notifier`,
|
|
built from `Dockerfile`/`docker-compose.yml` in this repo. Container name
|
|
`Lunch-Notifier`, port `2323`. No reverse proxy — accessed directly via
|
|
`http://<server-ip>:2323` on the LAN.
|
|
|
|
**Deploy mechanism: Forgejo push-to-deploy**, same pattern as the other
|
|
three projects on this box (`card-grader`, `n64-tracker`, `arr-summary`) —
|
|
see `card-grader`'s `CLAUDE.md` for the full pipeline mechanics (runner
|
|
config, HTTPS-not-SSH git auth, known tradeoffs). In short: push to `main`
|
|
on the `forgejo` remote (`https://git.hippofam.com/ninja_hippo/lunch-notifier.git`)
|
|
triggers `.forgejo/workflows/deploy.yml`, which syncs into
|
|
`/mnt/user/appdata/lunch-notifier` (excluding `.git`, `.env`, `data/`) and
|
|
runs `docker compose up -d --build`. **`.env` changes need an explicit
|
|
`docker compose up -d` on the server afterward** — env vars only load at
|
|
container start, a deploy that doesn't change code won't pick up a
|
|
manually-edited `.env` on its own.
|
|
|
|
**Persistent state, never touched by deploys:** `data/settings.json` (the
|
|
volume-mounted `/data` inside the container) holds the calendar pull
|
|
schedule — this is live user data, not deploy-managed. `.env` holds
|
|
`HA_WEBHOOK_URL` and is server-only, gitignored, never committed.
|
|
|
|
**Migration note (2026-08-25):** this container originally used a Docker
|
|
**named volume** (`lunch-notifier_lunch-settings`) instead of a bind mount.
|
|
That meant Unraid's Appdata Backup plugin — which only knows about
|
|
`/mnt/user/appdata` and `/mnt/cache/appdata` as backup sources — silently
|
|
skipped it entirely; the settings data was never included in any backup.
|
|
Migrated to a bind mount (`./data:/data`) with the existing settings copied
|
|
over first and verified intact before the old named volume was deleted.
|