All checks were successful
Deploy / deploy (push) Successful in 33s
Two HA entities are both named "Family" (calendar.family and calendar.family_2); the automation now targets _2 per user preference. Note it only supports create_event, not delete, so a bad push has to be cleaned up manually in Google Calendar rather than via the HA API. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KsJqAZ82Veyf5oihBr71S5
96 lines
5.3 KiB
Markdown
96 lines
5.3 KiB
Markdown
# Lunch Notifier
|
|
|
|
Single-file Flask app that scrapes the daily Mariposa (Kyrene school
|
|
district) lunch menu and texts it to a list of phone numbers via
|
|
email-to-SMS carrier gateways (e.g. `4805551234@vtext.com` for Verizon).
|
|
Runs as one Docker container on the user's Unraid home server.
|
|
|
|
## Architecture
|
|
|
|
- **`app.py`** — everything: Flask web UI/API, APScheduler-style job
|
|
scheduling (`schedule` library), and the email-to-SMS send logic
|
|
(`smtplib` via Gmail, app password auth). 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.
|
|
- **`scheduler.py`** — day-of-week/time scheduling helpers used by `app.py`.
|
|
- Settings (phone numbers, send time, days, vacation date ranges) persist
|
|
to `/data/settings.json`, not environment variables — env vars
|
|
(`PHONE_NUMBER`, `RUN_TIME`, `RUN_DAYS`) are defaults used only the very
|
|
first time, before a settings file exists.
|
|
- Vacation ranges suppress sends without disabling the schedule — checked
|
|
fresh on every run via `is_on_vacation()`.
|
|
- **Home Assistant integration (2026-09-02, HA side built 2026-09-03):** if
|
|
`HA_WEBHOOK_URL` is set, every successful menu fetch also POSTs
|
|
`{"date": ..., "items": [...]}` to that webhook, independent of whether
|
|
the SMS send itself succeeds. 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`), producing an all-day event with the menu as
|
|
the description (bulleted). This makes the scrape here the single
|
|
source of truth — HA doesn't poll or re-scrape anything.
|
|
`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 either way: re-pushing the same
|
|
date's menu creates a second event. 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.
|
|
- **Manual actions (2026-09-03):** besides "Send Lunch Menu Now" (full job —
|
|
scrape, HA push, text everyone), the UI has "Push to Calendar Only"
|
|
(`POST /api/push-calendar` → `push_calendar_only()`, scrape + HA push, no
|
|
texts — for testing the calendar sync without spamming phones) and
|
|
"Preview Today's Menu" (`GET /api/preview` → `_scrape_menu()`, pure dry
|
|
run, no side effects at all). `carrier_gateway` is editable in the
|
|
Schedule card (dropdown of common US carriers + custom domain). Gmail
|
|
credentials and `HA_WEBHOOK_URL` stay env-only, not settings.json — the
|
|
UI has no auth, so secrets aren't made editable there; `GET
|
|
/api/integrations` just exposes whether each is configured (booleans,
|
|
not values) so the UI can show a badge.
|
|
|
|
## Local development
|
|
|
|
```bash
|
|
pip install -r requirements.txt
|
|
python3 app.py
|
|
```
|
|
|
|
Needs `GMAIL_USER` and `GMAIL_APP_PASSWORD` env vars to actually send
|
|
(a Gmail **app password**, not the account password — required since Gmail
|
|
disallows plain SMTP auth with the real password). 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`.
|
|
|
|
**Persistent state, never touched by deploys:** `data/settings.json` (the
|
|
volume-mounted `/data` inside the container) holds phone numbers, schedule,
|
|
and vacation ranges — this is live user data, not deploy-managed. `.env`
|
|
holds `GMAIL_USER`/`GMAIL_APP_PASSWORD`/`PHONE_NUMBER` etc. 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.
|