From a9ea0a2d87b650df975c2fc7099ff4490a16b2dc Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Thu, 3 Sep 2026 07:05:31 -0700 Subject: [PATCH] Add Home Assistant calendar sync via webhook Every successful menu fetch now POSTs the day's items to HA_WEBHOOK_URL (when set), so an HA automation can turn it into a calendar event. Surface the push result as a "Calendar Sync" badge on the status card, hidden unless HA_WEBHOOK_URL is configured. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KsJqAZ82Veyf5oihBr71S5 --- .env.example | 4 ++++ CLAUDE.md | 6 ++++++ app.py | 38 +++++++++++++++++++++++++++++++++++--- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 17cc6bb..4ccf445 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,10 @@ GMAIL_USER=you@gmail.com GMAIL_APP_PASSWORD=xxxxxxxxxxxxxxxxxxxx # --- Optional --- +# Home Assistant webhook URL to push the day's menu to (for a calendar-event +# automation). Leave unset to skip. e.g. http://192.168.86.X:8123/api/webhook/ +HA_WEBHOOK_URL= + # Time to send the text each day (24-hour HH:MM, container timezone) RUN_TIME=07:00 diff --git a/CLAUDE.md b/CLAUDE.md index 8546ebb..aad396c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,12 @@ Runs as one Docker container on the user's Unraid home server. 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):** 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. An HA + automation (webhook trigger → `calendar.create_event`) turns this into an + all-day calendar event with the menu. This makes the scrape here the + single source of truth — HA doesn't poll or re-scrape anything. ## Local development diff --git a/app.py b/app.py index c1313ba..e2d750d 100644 --- a/app.py +++ b/app.py @@ -12,6 +12,7 @@ from datetime import date, datetime from email.message import EmailMessage from pathlib import Path +import requests import schedule from flask import Flask, jsonify, request @@ -34,7 +35,7 @@ _DEFAULTS = { "vacations": [], } -_status = {"last_run": None, "last_result": None, "last_message": None} +_status = {"last_run": None, "last_result": None, "last_message": None, "calendar_result": None} _lock = threading.Lock() @@ -82,6 +83,18 @@ def _send_email_sms(message: str, phone: str, gateway: str) -> None: log.info(f"Sent ✓ → {to_addr}") +def _push_to_home_assistant(items: list[str], date_str: str) -> bool: + """Returns True if a push was attempted and succeeded, False if skipped (not configured).""" + webhook_url = os.environ.get("HA_WEBHOOK_URL") + if not webhook_url: + return False + log.info(f"Pushing menu → Home Assistant webhook") + resp = requests.post(webhook_url, json={"date": date_str, "items": items}, timeout=10) + resp.raise_for_status() + log.info("Home Assistant push ✓") + return True + + def run_job() -> None: global _status settings = load_settings() @@ -104,6 +117,7 @@ def run_job() -> None: message = f"No lunch menu posted for {date_str} (may be a break or holiday)." except Exception as e: log.exception("Menu fetch failed") + items, date_str = [], None message = f"Error fetching Mariposa lunch menu: {e}" if len(message) > 1400: @@ -111,6 +125,14 @@ def run_job() -> None: log.info(f"Message:\n{message}") + calendar_result = "n/a" + if items: + try: + calendar_result = "ok" if _push_to_home_assistant(items, date_str) else "n/a" + except Exception as e: + log.error(f"Home Assistant push failed: {e}") + calendar_result = f"error: {e}" + errors = [] for phone in phones: try: @@ -123,6 +145,7 @@ def run_job() -> None: "last_run": datetime.now().isoformat(), "last_result": "error: " + "; ".join(errors) if errors else "ok", "last_message": message, + "calendar_result": calendar_result, } @@ -168,8 +191,7 @@ def index(): @app.route("/icon.png") def icon(): - import requests as req - r = req.get(MASCOT_URL, timeout=10) + r = requests.get(MASCOT_URL, timeout=10) return r.content, 200, {"Content-Type": "image/png", "Cache-Control": "public, max-age=86400"} @@ -346,6 +368,7 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
Result
—
Next Scheduled
—
Mode
—
+ @@ -479,6 +502,15 @@ function applyStatus(st) { document.getElementById('s-mode').innerHTML = vac ? `Vacation: ${vac}` : 'Active'; + const calStat = document.getElementById('s-cal-stat'); + const cal = st.calendar_result; + if (cal && cal !== 'n/a') { + calStat.style.display = ''; + const calCls = cal === 'ok' ? 'badge-green' : 'badge-red'; + document.getElementById('s-cal').innerHTML = `${cal}`; + } else { + calStat.style.display = 'none'; + } const msgEl = document.getElementById('s-last-msg'); if (st.last_message) { msgEl.textContent = st.last_message;