All checks were successful
Deploy / deploy (push) Successful in 31s
The wall dashboard's Ombi tab now shows a lunch panel with pull time and next scheduled run. It's served from Home Assistant (:8123), a different origin, so allow that one origin to read the one read-only endpoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
12 KiB
Markdown
205 lines
12 KiB
Markdown
# Lunch Notifier
|
||
|
||
Single-file Flask app that scrapes the 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.
|
||
|
||
**2026-09-29: monthly → daily pulls, HA-side dedup.** See "Pull scheduler"
|
||
and "Home Assistant integration" below. Anything describing a day-25
|
||
monthly pull or "no dedup, re-pushing creates duplicates" describes the
|
||
pre-2026-09-29 app.
|
||
|
||
**2026-09-08: pivoted from Nutrislice to a Claude-read PDF, and from
|
||
weekly to monthly pulls.** The district discontinued Nutrislice (the old
|
||
scraper hit `kyrene.api.nutrislice.com`, a clean per-day JSON API) in
|
||
favor of publishing one monthly menu PDF per grade band on
|
||
`kyrene.org/family-student-resources/food-services/school-menus`. See
|
||
"Menu source" and "Daily pull scheduler" below for how that's handled
|
||
now. Anything describing a weekly Nutrislice pull describes the
|
||
pre-2026-09-08 app.
|
||
|
||
## 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 the currently-published K-5 lunch menu PDF and
|
||
asks Claude (Anthropic API, `claude-opus-5`, structured JSON output via
|
||
`output_config.format`) to read it into `[{"date": ..., "items": [...]}, ...]`
|
||
for every day of whatever month is published. Days with no posted menu
|
||
(weekends, breaks, holidays) come back with an empty `items` list rather
|
||
than being omitted — same contract the old Nutrislice-based scraper used,
|
||
so `app.py`'s push logic didn't need to change.
|
||
- **Menu source (Nutrislice → PDF, 2026-09-08):** the district's page links
|
||
a *stable* Finalsite "resource manager" permalink
|
||
(`kyrene.org/fs/resource-manager/view/32bb3f8b-5a50-4b38-ad68-a5d3108de571`
|
||
for the K-5/"Standard" menu — there's a different UUID per grade band/
|
||
campus on that page) that 302-redirects to whichever monthly PDF is
|
||
currently live (`scraper.MENU_PAGE_URL`). **Don't hardcode the PDF URL
|
||
itself** — it's a Finalsite CDN path that changes every month (verified:
|
||
the September 2026 PDF and August's page both linked through the same
|
||
permalink); only the permalink UUID is stable, and only changes if the
|
||
district restructures that page entirely. The PDF itself is a single-page
|
||
graphic calendar grid (mascots, colored cells, day numbers laid out
|
||
visually) — not text-extractable in any reliable way (raw text extraction
|
||
jumbles day numbers away from their menu items), which is why this reads
|
||
it via Claude vision/document understanding rather than `pdfplumber`/
|
||
regex. Cost is negligible: one ~370KB single-page PDF per month, well
|
||
under a cent of Opus 5 tokens.
|
||
- **`ANTHROPIC_API_KEY`** is now required (server `.env`, alongside
|
||
`HA_WEBHOOK_URL`) — `scraper.get_month_menu()` fails without it, same
|
||
fail-soft-into-`_status`-error behavior as a Home Assistant push failure.
|
||
`GET /api/integrations` reports both keys' configured/not-configured
|
||
state.
|
||
- Settings (just `calendar_run_time`) persist to `/data/settings.json`, not
|
||
environment variables — the env var `CALENDAR_RUN_TIME` is a default used
|
||
only before a settings file exists. `_DEFAULTS` is the complete schema:
|
||
`load_settings()` merges the file *over* `_DEFAULTS` (so an old file
|
||
missing a key still yields a valid schedule instead of zero jobs) and
|
||
**keeps only keys in `_DEFAULTS`**; startup rewrites the file with that
|
||
result. That pruning (2026-09-29) removed the SMS era's phone numbers /
|
||
carrier gateway / vacations and the old `calendar_run_day_of_month` from
|
||
the server's settings.json — they had lingered, unread, since the pivots.
|
||
- **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_month_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 required — 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 shared iCloud "Family" calendar, via CalDAV —
|
||
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` supports
|
||
`create_event` and `get_events` but not `delete_event` — a bad push can't
|
||
be cleaned up via the HA API, only by hand in the Calendar app.
|
||
**Automation mode history — `queued` since 2026-09-29.** The app fires
|
||
one webhook POST per school day back-to-back (HA answers each POST
|
||
immediately, so the app never waits on the calendar write). `single`
|
||
(default) dropped all but the first as `failed_single` (fixed 2026-09-03
|
||
→ `parallel`); `parallel` then sent ~20 simultaneous CalDAV PUTs on a
|
||
month push, overflowing iCloud (connection pool full + ~10× `500
|
||
Internal Server Error` on the 2026-09-25 push). Now `queued` (max 40):
|
||
one write at a time, nothing dropped. **Dedup (2026-09-29):** before
|
||
creating, the automation reads the date with `calendar.get_events` and
|
||
stops if a `🍱 Mariposa Lunch` event already exists — that's what makes
|
||
daily pulls safe. It verifies the event saved and retries once after 15s.
|
||
Pre-dedup pushes left 2-3 copies on most early-September days; those
|
||
have to be deleted by hand. 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.
|
||
- **Daily pull scheduler (monthly → daily, 2026-09-29):** `push_month_to_calendar()`
|
||
runs every day at `calendar_run_time` (default 18:00; `schedule` job
|
||
tagged `"calendar"` — `schedule.clear("calendar")`, not a bare
|
||
`schedule.clear()`, so a future second schedule wouldn't stomp it).
|
||
**Why daily:** the old once-a-month day-25 pull pushed whatever month was
|
||
live at the permalink; when the district posted late (September 2026: the
|
||
permalink still served September on 09-29), it silently re-pushed the
|
||
current month and the next month's menu wouldn't be pulled until the
|
||
following 25th — most of that month gone. Daily pulls pick up a new month
|
||
the evening it's posted; HA-side dedup makes the repeats harmless, and the
|
||
Claude read costs well under a cent a day. Each pull pushes **only today
|
||
onward** (past dates are skipped — they're already on the calendar or no
|
||
longer matter) and **skips weekend dates that have items**, logging a
|
||
warning and noting it in the status result: there's never school lunch on
|
||
a weekend, so that's a misread date (seen once: Fri 9/25's menu landed on
|
||
Sat 9/26). Whatever month is live at the permalink is what gets read —
|
||
there's no logic forcing "next month specifically."
|
||
- **iPad dashboard reads `/api/status` (2026-09-29).** The HA iPad wall
|
||
dashboard's Ombi tab shows a lunch panel: today's menu (read from the HA
|
||
calendar, not this app) plus pull time / next scheduled from
|
||
`/api/status`. That page is served from HA (`http://192.168.86.207:8123`),
|
||
a different origin, so `_cors_for_dashboard` adds
|
||
`Access-Control-Allow-Origin` for that one origin on that one read-only
|
||
endpoint. Changing `/api/status`'s shape (`next_calendar_run`) breaks
|
||
that panel; moving HA's address means updating `DASHBOARD_ORIGIN`.
|
||
- **Manual actions:** "Push This Month to Calendar" (`POST /api/push-calendar`
|
||
→ `push_month_to_calendar()`, same function the schedule calls — so it
|
||
also pushes only today onward and skips weekend misreads) and
|
||
"Preview This Month's Menu" (`GET /api/preview` → `get_month_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 `ANTHROPIC_API_KEY` set for anything menu-related to work at all
|
||
(including Preview, since that calls the same Claude-backed scraper) and
|
||
`HA_WEBHOOK_URL` set to actually push anywhere — without the latter 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 `ANTHROPIC_API_KEY` and is server-only, gitignored,
|
||
never committed.
|
||
|
||
**Container timezone (fixed 2026-09-08):** the container previously had no
|
||
timezone configured — `python:3.12-slim` defaults to UTC and the
|
||
`/etc/localtime` bind mount in `docker-compose.yml` was commented out — so
|
||
`calendar_run_time` (and the `datetime.now()` call in `scraper.py` that
|
||
picks which week to fetch) were evaluated against UTC, not the user's
|
||
local clock. This happened to be masked because Arizona (no DST) and
|
||
Pacific Daylight Time are both UTC-7 right now, but would have silently
|
||
drifted the actual fire time by an hour once PDT becomes PST in November.
|
||
Fixed by uncommenting the `/etc/localtime:/etc/localtime:ro` bind mount
|
||
and installing `tzdata` in the `Dockerfile` — without `tzdata`, the
|
||
host's `/etc/localtime` (typically a symlink into `/usr/share/zoneinfo/…`)
|
||
would dangle inside the container since that directory wouldn't exist,
|
||
silently falling back to UTC again. Found while investigating why the
|
||
first automatic Sunday calendar push after the 2026-09-03 pivot appeared
|
||
not to run — that specific miss turned out to be unrelated (the school's
|
||
Nutrislice menu was published empty for the whole week, so `_push_calendar`
|
||
correctly skipped every day), but this latent TZ bug surfaced during the
|
||
investigation and was worth fixing regardless.
|
||
|
||
**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.
|