Add Home Assistant calendar sync via webhook
All checks were successful
Deploy / deploy (push) Successful in 42s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KsJqAZ82Veyf5oihBr71S5
This commit is contained in:
Barely Removable 2026-09-03 07:05:31 -07:00
parent 83533d339d
commit a9ea0a2d87
3 changed files with 45 additions and 3 deletions

View file

@ -16,6 +16,10 @@ GMAIL_USER=you@gmail.com
GMAIL_APP_PASSWORD=xxxxxxxxxxxxxxxxxxxx GMAIL_APP_PASSWORD=xxxxxxxxxxxxxxxxxxxx
# --- Optional --- # --- 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/<id>
HA_WEBHOOK_URL=
# Time to send the text each day (24-hour HH:MM, container timezone) # Time to send the text each day (24-hour HH:MM, container timezone)
RUN_TIME=07:00 RUN_TIME=07:00

View file

@ -20,6 +20,12 @@ Runs as one Docker container on the user's Unraid home server.
first time, before a settings file exists. first time, before a settings file exists.
- Vacation ranges suppress sends without disabling the schedule — checked - Vacation ranges suppress sends without disabling the schedule — checked
fresh on every run via `is_on_vacation()`. 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 ## Local development

38
app.py
View file

@ -12,6 +12,7 @@ from datetime import date, datetime
from email.message import EmailMessage from email.message import EmailMessage
from pathlib import Path from pathlib import Path
import requests
import schedule import schedule
from flask import Flask, jsonify, request from flask import Flask, jsonify, request
@ -34,7 +35,7 @@ _DEFAULTS = {
"vacations": [], "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() _lock = threading.Lock()
@ -82,6 +83,18 @@ def _send_email_sms(message: str, phone: str, gateway: str) -> None:
log.info(f"Sent ✓ → {to_addr}") 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: def run_job() -> None:
global _status global _status
settings = load_settings() 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)." message = f"No lunch menu posted for {date_str} (may be a break or holiday)."
except Exception as e: except Exception as e:
log.exception("Menu fetch failed") log.exception("Menu fetch failed")
items, date_str = [], None
message = f"Error fetching Mariposa lunch menu: {e}" message = f"Error fetching Mariposa lunch menu: {e}"
if len(message) > 1400: if len(message) > 1400:
@ -111,6 +125,14 @@ def run_job() -> None:
log.info(f"Message:\n{message}") 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 = [] errors = []
for phone in phones: for phone in phones:
try: try:
@ -123,6 +145,7 @@ def run_job() -> None:
"last_run": datetime.now().isoformat(), "last_run": datetime.now().isoformat(),
"last_result": "error: " + "; ".join(errors) if errors else "ok", "last_result": "error: " + "; ".join(errors) if errors else "ok",
"last_message": message, "last_message": message,
"calendar_result": calendar_result,
} }
@ -168,8 +191,7 @@ def index():
@app.route("/icon.png") @app.route("/icon.png")
def icon(): def icon():
import requests as req r = requests.get(MASCOT_URL, timeout=10)
r = req.get(MASCOT_URL, timeout=10)
return r.content, 200, {"Content-Type": "image/png", "Cache-Control": "public, max-age=86400"} 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
<div class="stat"><dt>Result</dt><dd id="s-result">—</dd></div> <div class="stat"><dt>Result</dt><dd id="s-result">—</dd></div>
<div class="stat"><dt>Next Scheduled</dt><dd id="s-next">—</dd></div> <div class="stat"><dt>Next Scheduled</dt><dd id="s-next">—</dd></div>
<div class="stat"><dt>Mode</dt><dd id="s-mode">—</dd></div> <div class="stat"><dt>Mode</dt><dd id="s-mode">—</dd></div>
<div class="stat" id="s-cal-stat" style="display:none;"><dt>Calendar Sync</dt><dd id="s-cal">—</dd></div>
</dl> </dl>
<div id="s-last-msg" style="display:none;margin-top:14px;padding:10px 12px;background:#1c2128;border-radius:8px;font-size:.8rem;color:#c9d1d9;white-space:pre-wrap;"></div> <div id="s-last-msg" style="display:none;margin-top:14px;padding:10px 12px;background:#1c2128;border-radius:8px;font-size:.8rem;color:#c9d1d9;white-space:pre-wrap;"></div>
</div> </div>
@ -479,6 +502,15 @@ function applyStatus(st) {
document.getElementById('s-mode').innerHTML = vac document.getElementById('s-mode').innerHTML = vac
? `<span class="badge badge-yellow">Vacation: ${vac}</span>` ? `<span class="badge badge-yellow">Vacation: ${vac}</span>`
: '<span class="badge badge-green">Active</span>'; : '<span class="badge badge-green">Active</span>';
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 = `<span class="badge ${calCls}">${cal}</span>`;
} else {
calStat.style.display = 'none';
}
const msgEl = document.getElementById('s-last-msg'); const msgEl = document.getElementById('s-last-msg');
if (st.last_message) { if (st.last_message) {
msgEl.textContent = st.last_message; msgEl.textContent = st.last_message;