From d5c64692bacbdc51623bdce6f81996f470deee0c Mon Sep 17 00:00:00 2001 From: "Rich O." Date: Thu, 22 Oct 2026 21:21:22 -0400 Subject: [PATCH] Show the kids' Nextcloud calendar events for the day The page runs from file:// and Nextcloud sends no CORS headers, so a cron job (fetch-calendars.py) pulls each public calendar via CalDAV REPORT with server-side recurrence expansion and writes events.js for the page to load. Events shared by both kids show once. On busy days the hourly icons and then extra event rows give way so everything fits on the 800x480 screen. install.sh gains --calendar "Name=URL" (repeatable) and --no-calendars; share links live in calendars.conf on the Pi, not in the repo. --- .gitignore | 2 + README.md | 6 ++ bin/fetch-calendars.py | 134 +++++++++++++++++++++++++++++++++++++++++ dashboard/index.html | 68 ++++++++++++++++++++- install.sh | 32 ++++++++-- 5 files changed, 236 insertions(+), 6 deletions(-) create mode 100755 bin/fetch-calendars.py diff --git a/.gitignore b/.gitignore index a1d94ff..6dea906 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .DS_Store __pycache__/ shots/ +events.js +calendars.conf diff --git a/README.md b/README.md index a2fe154..3e51c91 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ touchscreen and works on any screen. If the internet drops, it keeps showing the last forecast. - **Outfit:** based on the "feels like" temperature for the hours ahead (7am–6pm), plus rain, snow, UV and wind. After 7pm it switches to tomorrow, so clothes can be picked the night before. +- **Calendars:** shows the kids' events for the same day from Nextcloud public calendar links (optional). - **Night mode:** the screen turns off overnight (default 21:00–06:30). Tapping it wakes it for a minute. ## Install on a Pi @@ -39,6 +40,8 @@ The dashboard reloads by itself. | `--off HHMM` / `--on HHMM` | `2100` / `0630` | When the screen goes off and comes back | | `--awake SECONDS` | `60` | How long a tap keeps the screen on at night | | `--no-night-off` / `--night-off` | night-off on | Keep the screen on 24/7, or turn night-off back on | +| `--calendar "Name=URL"` | none | A kid's calendar; repeat once per kid. URL is a Nextcloud public share link (`…/apps/calendar/p/…`) | +| `--no-calendars` | | Stop showing calendars | | `--uninstall` | | Remove everything (packages and autologin are left alone) | ## Layout @@ -48,6 +51,7 @@ dashboard/index.html the whole dashboard: one file, inline SVG art, no depen bin/kiosk.sh launches Chromium full-screen with its own profile bin/screen.sh screen on|off|auto (auto = whatever the schedule says right now) bin/tap-wake.py watches the touchscreen; wakes the screen when it's off at night +bin/fetch-calendars.py cron job: fetches the kids' calendars into events.js (the page can't, from file://) systemd/tap-wake.service user service for tap-wake.py install.sh installer / updater / uninstaller ``` @@ -70,4 +74,6 @@ Open `dashboard/index.html?demo=LOW,HIGH,CODE[,RAIN%][,UV]` in any browser to fa - `?demo=50,64,61,80`: rain (raincoat, rain boots, umbrella) - `?demo=72,88,0,0,8`: hot and sunny (t-shirt, shorts, sun hat, sunglasses) +Demo mode also shows three sample calendar events; add `,noevents` (e.g. `?demo=45,58,0,0,3,noevents`) to hide them. + The clothing thresholds live in `outfit()` in `index.html`. Temperatures are in °F. diff --git a/bin/fetch-calendars.py b/bin/fetch-calendars.py new file mode 100755 index 0000000..b63e73a --- /dev/null +++ b/bin/fetch-calendars.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Fetches the kids' Nextcloud calendars into events.js for the dashboard. + +The dashboard runs from file:// and Nextcloud sends no CORS headers, so the page can't +fetch calendars itself. Cron runs this every 15 minutes instead. Nextcloud expands +repeating events for us (CalDAV REPORT with ), so no RRULE handling is needed. + +calendars.conf (next to this script) has one calendar per line: Name=share-link +""" +import datetime as dt, json, os, re, sys, urllib.request +import xml.etree.ElementTree as ET +from zoneinfo import ZoneInfo + +DIR = os.path.dirname(os.path.realpath(__file__)) +CONF = os.path.join(DIR, "calendars.conf") +OUT = os.path.join(DIR, "events.js") +COLORS = ["#3B6FB6", "#E85D9E", "#3BAF6A", "#F28C38"] +DAYS_AHEAD = 2 # today and tomorrow; the dashboard only shows the day it's planning for + +QUERY = """ + + + + + +""" + +def calendars(): + cals = [] + try: + for line in open(CONF): + name, _, url = line.split("#")[0].strip().partition("=") + if name.strip() and url.strip(): + cals.append((name.strip(), url.strip())) + except OSError: + pass + return cals + +def dav_url(url): + """Share link (…/apps/calendar/p/TOKEN) or CalDAV URL -> public CalDAV collection URL.""" + m = re.match(r"(https?://[^/]+)(?:/index\.php)?/(?:apps/calendar/(?:embed|p)|remote\.php/dav/public-calendars)/([A-Za-z0-9]+)", url) + if not m: + raise ValueError(f"not a Nextcloud public calendar link: {url}") + return f"{m[1]}/remote.php/dav/public-calendars/{m[2]}/" + +def unescape(s): + return re.sub(r"\\(.)", lambda m: "\n" if m[1] in "nN" else m[1], s) + +def when(prop, value): + """-> ('2026-10-30', True) for all-day, or (UTC ISO string, False) for timed.""" + if "VALUE=DATE" in prop or re.fullmatch(r"\d{8}", value): + return f"{value[:4]}-{value[4:6]}-{value[6:8]}", True + t = dt.datetime.strptime(value.rstrip("Z"), "%Y%m%dT%H%M%S") + if value.endswith("Z"): + t = t.replace(tzinfo=dt.timezone.utc) + else: + tzid = re.search(r"TZID=([^;:]+)", prop) + t = t.replace(tzinfo=ZoneInfo(tzid[1])) if tzid else t.astimezone() + return t.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), False + +def parse(ics): + events, ev = [], None + for line in re.sub(r"\r?\n[ \t]", "", ics).splitlines(): + prop, _, value = line.partition(":") + name = prop.split(";")[0].upper() + if line == "BEGIN:VEVENT": + ev = {} + elif line == "END:VEVENT" and ev is not None: + if "start" in ev and ev.get("status") != "CANCELLED": + ev.setdefault("end", ev["start"]) + ev.setdefault("title", "(no title)") + ev.pop("status", None) + events.append(ev) + ev = None + elif ev is not None: + if name == "SUMMARY": + ev["title"] = unescape(value).strip() + elif name == "DTSTART": + ev["start"], ev["allDay"] = when(prop, value) + elif name == "DTEND": + ev["end"] = when(prop, value)[0] + elif name == "STATUS": + ev["status"] = value.strip().upper() + return events + +def fetch(url, start, end): + fmt = lambda t: t.astimezone(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + req = urllib.request.Request(dav_url(url), method="REPORT", data=QUERY.format(fmt(start), fmt(end)).encode(), + headers={"Depth": "1", "Content-Type": "application/xml; charset=utf-8"}) + with urllib.request.urlopen(req, timeout=20) as res: + root = ET.fromstring(res.read()) + events = [] + for data in root.iter("{urn:ietf:params:xml:ns:caldav}calendar-data"): + events += parse(data.text or "") + return events + +def previous(): + try: + return json.loads(open(OUT).read().split("window.KIDS_EVENTS = ", 1)[1].rstrip().rstrip(";")) + except (OSError, IndexError, ValueError): + return {} + +def main(): + today = dt.datetime.now().astimezone().replace(hour=0, minute=0, second=0, microsecond=0) + end = today + dt.timedelta(days=DAYS_AHEAD) + prev = previous().get("calendars", {}) + cals, failed = {}, [] + for i, (name, url) in enumerate(calendars()): + try: + cals[name] = {"color": COLORS[i % len(COLORS)], "events": fetch(url, today, end)} + except Exception as e: # keep the last good copy so a network blip doesn't blank the screen + print(f"{name}: {e}", file=sys.stderr) + failed.append(name) + cals[name] = {"color": COLORS[i % len(COLORS)], "events": prev.get(name, {}).get("events", [])} + + # the same event on more than one kid's calendar (e.g. Early Dismissal) shows once + merged = {} + for name, cal in cals.items(): + for ev in cal["events"]: + key = (ev["title"].lower(), ev["start"]) + m = merged.setdefault(key, dict(ev, who=[], colors=[])) + if name not in m["who"]: + m["who"].append(name); m["colors"].append(cal["color"]) + events = sorted(merged.values(), key=lambda e: (e["start"][:10], not e["allDay"], e["start"])) + + out = {"fetched": int(dt.datetime.now().timestamp() * 1000), "failed": failed, "calendars": cals, "events": events} + tmp = OUT + ".tmp" + with open(tmp, "w") as f: + f.write("// Written by fetch-calendars.py. Don't edit.\nwindow.KIDS_EVENTS = " + json.dumps(out) + ";\n") + os.replace(tmp, OUT) + return 1 if failed and len(failed) == len(cals) else 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dashboard/index.html b/dashboard/index.html index c8dd7ad..f318b0c 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -18,7 +18,7 @@ body{ } #scene{width:100%;height:100vh;display:block} .ground{fill:var(--ground)} .ground2{fill:var(--ground2)} -#info{display:flex;flex-direction:column;gap:9px;padding:12px 16px 12px 4px;min-width:0;position:relative} +#info{display:flex;flex-direction:column;gap:9px;padding:12px 16px 15px 4px;min-width:0;height:100vh;position:relative} header{display:flex;align-items:baseline;justify-content:space-between} .day{font-size:34px;font-weight:900;line-height:1} @@ -51,6 +51,17 @@ header{display:flex;align-items:baseline;justify-content:space-between} .chip{display:flex;align-items:center;gap:6px;font-size:18px;font-weight:800;background:#fff;border-radius:999px;padding:3px 12px 3px 6px;box-shadow:0 1px 0 rgba(0,0,0,.08)} .chip i{width:18px;height:18px;border-radius:50%;display:inline-block;border:2px solid rgba(0,0,0,.12)} .note{font-size:16px;font-weight:800;color:#b5482f} +.events{background:var(--card);border-radius:18px;padding:7px 14px 8px;display:flex;flex-direction:column;gap:4px;flex:none} +.events:empty{display:none} +.ev{display:flex;align-items:center;gap:10px;font-size:18px;font-weight:800;line-height:1.15;white-space:nowrap} +.ev.past{opacity:.45} +.ev .who{display:flex;align-items:center;gap:5px;flex:none;color:var(--muted)} +.ev .who i{width:14px;height:14px;border-radius:50%;display:inline-block} +.ev .when{flex:none;font-weight:900} +.ev .what{overflow:hidden;text-overflow:ellipsis;min-width:0} +.ev.more{font-size:15px;color:var(--muted)} +body.tight .slot svg{display:none} /* busy day: drop the hourly icons to make room for events */ +body.tight .slot{padding-top:4px} #status{position:absolute;right:16px;bottom:2px;font-size:11px;font-weight:700;color:var(--muted);opacity:.7} #status.bad{color:#c0392b;opacity:1;font-size:13px} @@ -257,11 +268,12 @@ body.cold .cheek{opacity:.95}
-
+
+
@@ -477,8 +489,19 @@ const API = "https://api.open-meteo.com/v1/forecast?" + new URLSearchParams({ daily: "weather_code,temperature_2m_max,temperature_2m_min,apparent_temperature_max", }); +// kids' calendars: cron runs fetch-calendars.py, which writes events.js next to this page +// (the calendars can't be fetched from file:// directly). Re-read it on every load. +function loadEvents(){ + const old = $("eventsScript"), el = document.createElement("script"); + el.id = "eventsScript"; el.src = "events.js?t=" + Date.now(); + el.onload = el.onerror = render; + if (old) old.remove(); + document.head.appendChild(el); +} + async function load(){ if (DEMO) { data = DEMO; render(); return; } + loadEvents(); try { const res = await fetch(API, { cache: "no-store" }); if (!res.ok) throw new Error("HTTP " + res.status); @@ -556,6 +579,17 @@ function render(){ $("chips").innerHTML = plan.chips.map(([l, c]) => `${l}`).join(""); $("note").textContent = plan.note; + // the kids' events for the same day + // if it doesn't all fit, first drop the hourly icons, then show fewer events + const overflows = () => $("wear").scrollHeight > $("wear").clientHeight + 1; + document.body.classList.remove("tight"); + for (let max = 3; max >= 1; max--){ + $("events").innerHTML = eventRows(dateStr, tomorrow ? null : now, max); + if (!overflows()) break; + document.body.classList.add("tight"); + if (!overflows()) break; + } + // status line const t = data._fetched ? new Date(data._fetched) : null; const stale = t && (Date.now() - t) > 3 * 3600e3; @@ -564,6 +598,27 @@ function render(){ $("status").className = (lastError || stale) && !DEMO ? "bad" : ""; } +function eventRows(dateStr, now, max){ + const all = (window.KIDS_EVENTS && window.KIDS_EVENTS.events) || []; + const dayStart = new Date(dateStr + "T00:00"), dayEnd = new Date(dayStart); dayEnd.setDate(dayEnd.getDate() + 1); + const evs = all.filter(e => e.allDay + ? dateStr === e.start || (e.start < dateStr && dateStr < e.end) + : new Date(e.start) < dayEnd && new Date(e.end) > dayStart); + const clock = d => (d.getHours() % 12 || 12) + (d.getMinutes() ? ":" + pad(d.getMinutes()) : "") + (d.getHours() < 12 ? "am" : "pm"); + const esc = t => t.replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); + const shown = evs.length > max ? evs.slice(0, max - 1) : evs; + const rows = shown.map(e => { + const st = new Date(e.start), past = !e.allDay && now && new Date(e.end) <= now; + const when = e.allDay ? "All day" : st < dayStart ? "Until " + clock(new Date(e.end)) : clock(st); + const dots = e.colors.map(c => ``).join(""); + return `
${dots}${esc(e.who.join(" & "))} + ${when}${esc(e.title)}
`; + }); + if (evs.length > shown.length) rows.push(`
+${evs.length - shown.length} more
`); + if (max === 1 && evs.length > 1) return `
${evs.length} things on the calendar
`; + return rows.join(""); +} + // ---- demo mode: index.html?demo=lo,hi,code[,rain%][,uv] e.g. ?demo=28,38,73 ---- const DEMO = (() => { const m = location.search.match(/demo=([\d.,-]+)/); if (!m) return null; @@ -580,12 +635,21 @@ const DEMO = (() => { return { hourly: H, current: { temperature_2m: (lo + hi) / 2, apparent_temperature: (lo + hi) / 2, weather_code: code, is_day: 1 }, daily: { time: days, weather_code: [code, code], temperature_2m_max: [hi, hi], temperature_2m_min: [lo, lo], apparent_temperature_max: [hi, hi] }, _fetched: Date.now() }; })(); +if (DEMO && !window.KIDS_EVENTS && !/noevents/.test(location.search)){ + const at = (d, hr) => new Date(`${d}T${pad(hr)}:00`).toISOString(); + window.KIDS_EVENTS = { events: DEMO.daily.time.flatMap(d => [ + { who: ["Nate", "Vi"], colors: ["#3B6FB6", "#E85D9E"], title: "Early Dismissal (11:50am)", allDay: true, start: d, end: d }, + { who: ["Vi"], colors: ["#E85D9E"], title: "Soccer practice at Merion field", allDay: false, start: at(d, 16), end: at(d, 17) }, + { who: ["Nate"], colors: ["#3B6FB6"], title: "Cub Scouts", allDay: false, start: at(d, 18), end: at(d, 19) }, + ]) }; +} // ---- go --------------------------------------------------------------------- document.addEventListener("contextmenu", e => e.preventDefault()); load(); setInterval(load, CFG.refreshMinutes * 60e3); setInterval(render, 20e3); +addEventListener("resize", render); // fresh page once a day (keeps Chromium tidy on a long-running kiosk) setInterval(() => { const n = new Date(); if (n.getHours() === 3 && n.getMinutes() < 2) location.reload(); }, 60e3); diff --git a/install.sh b/install.sh index b346329..43b7e28 100755 --- a/install.sh +++ b/install.sh @@ -6,6 +6,8 @@ # ./install.sh --lat 40.0 --lon -75.3 # set location explicitly # ./install.sh --off 2030 --on 0700 # change the night-off schedule # ./install.sh --no-night-off # keep the screen on 24/7 +# ./install.sh --calendar "Nate=URL" # show a kid's Nextcloud calendar (repeat per kid) +# ./install.sh --no-calendars # stop showing calendars # ./install.sh --uninstall # # Safe to re-run: settings you don't pass are kept from the previous install. @@ -21,10 +23,10 @@ say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; } -usage() { sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; } +usage() { sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; } # ---- arguments ------------------------------------------------------------------------- -LAT= LON= OFF_AT= ON_AT= AWAKE_SECONDS= NIGHT_OFF= UNINSTALL=0 +LAT= LON= OFF_AT= ON_AT= AWAKE_SECONDS= NIGHT_OFF= UNINSTALL=0 CALENDARS=() NO_CALENDARS=0 while [ $# -gt 0 ]; do case "$1" in --lat) LAT=${2:?}; shift ;; @@ -34,6 +36,8 @@ while [ $# -gt 0 ]; do --awake) AWAKE_SECONDS=${2:?}; shift ;; --no-night-off) NIGHT_OFF=0 ;; --night-off) NIGHT_OFF=1 ;; + --calendar) CALENDARS+=("${2:?}"); shift ;; + --no-calendars) NO_CALENDARS=1 ;; --uninstall) UNINSTALL=1 ;; -h|--help) usage ;; *) warn "unknown option: $1"; usage 1 ;; @@ -44,7 +48,7 @@ done [ "$(id -u)" -ne 0 ] || die "run this as the desktop user (e.g. 'pi'), not root; it uses sudo where needed." [ "$REPO" != "$(cd "$DEST" 2>/dev/null && pwd)" ] || die "the repo can't live in $DEST (that's where it installs to); clone it somewhere else." -remove_cron() { (crontab -l 2>/dev/null || true) | { grep -v 'kids-dashboard/screen.sh' || true; } | crontab -; } +remove_cron() { (crontab -l 2>/dev/null || true) | { grep -vE 'kids-dashboard/(screen\.sh|fetch-calendars\.py)' || true; } | crontab -; } restart_kiosk() { pkill -f -- "--user-data-dir=$PROFILE" 2>/dev/null || true; } # lwrespawn relaunches it # ---- uninstall ------------------------------------------------------------------------- @@ -82,6 +86,9 @@ fi [[ $OFF_AT =~ ^([01][0-9]|2[0-3])[0-5][0-9]$ ]] || die "--off must be HHMM (24h), got '$OFF_AT'" [[ $ON_AT =~ ^([01][0-9]|2[0-3])[0-5][0-9]$ ]] || die "--on must be HHMM (24h), got '$ON_AT'" [[ $AWAKE_SECONDS =~ ^[0-9]+$ ]] || die "--awake must be a number of seconds" +for c in ${CALENDARS[@]+"${CALENDARS[@]}"}; do + [[ $c =~ ^[^=]+=https?://.+ ]] || die "--calendar must be Name=URL, got '$c'" +done # ---- sanity checks ----------------------------------------------------------------------- command -v raspi-config >/dev/null || warn "raspi-config not found; this is meant for Raspberry Pi OS. Continuing anyway." @@ -131,7 +138,7 @@ fi say "Installing to $DEST" mkdir -p "$DEST" install -m 644 "$REPO/dashboard/index.html" "$DEST/index.html" -install -m 755 "$REPO/bin/kiosk.sh" "$REPO/bin/screen.sh" "$REPO/bin/tap-wake.py" "$DEST/" +install -m 755 "$REPO/bin/kiosk.sh" "$REPO/bin/screen.sh" "$REPO/bin/tap-wake.py" "$REPO/bin/fetch-calendars.py" "$DEST/" cat > "$DEST/config.js" < "$DEST/calendars.conf" + chmod 600 "$DEST/calendars.conf" # the share links work like passwords +elif [ "$NO_CALENDARS" = 1 ]; then + rm -f "$DEST/calendars.conf" "$DEST/events.js" +fi + # ---- start at login ---------------------------------------------------------------------- mkdir -p "$(dirname "$AUTOSTART")" touch "$AUTOSTART" @@ -159,6 +175,14 @@ fi # ---- night-off schedule + tap to wake ----------------------------------------------------- remove_cron +if [ -s "$DEST/calendars.conf" ]; then + say "Calendars: $(grep -v '^#' "$DEST/calendars.conf" | cut -d= -f1 | paste -sd, - | sed 's/,/, /g')" + { crontab -l 2>/dev/null || true + echo "*/15 * * * * $DEST/fetch-calendars.py >$DEST/calendars.log 2>&1" + echo "@reboot sleep 60; $DEST/fetch-calendars.py >$DEST/calendars.log 2>&1" + } | crontab - + "$DEST/fetch-calendars.py" || warn "couldn't fetch the calendars right now; will retry every 15 minutes" +fi mkdir -p "$HOME/.config/systemd/user" if [ "$NIGHT_OFF" = 1 ]; then say "Screen off $OFF_AT-$ON_AT, tap to wake for ${AWAKE_SECONDS}s"