Add Home Assistant calendar sync via webhook
All checks were successful
Deploy / deploy (push) Successful in 42s
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:
parent
83533d339d
commit
a9ea0a2d87
3 changed files with 45 additions and 3 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
38
app.py
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue