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.
This commit is contained in:
rich committed 2026-10-22 21:21:22 -04:00
1 parent 9e579bf8de
commit d5c64692ba
5 files changed
+236 -6

No files matched your search

+2
View File
@@ -1,3 +1,5 @@
.DS_Store
__pycache__/
shots/
events.js
calendars.conf
+6
View File
@@ -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.
+134
View File
@@ -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 <expand>), 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 = """<?xml version="1.0"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><c:calendar-data><c:expand start="{0}" end="{1}"/></c:calendar-data></d:prop>
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT">
<c:time-range start="{0}" end="{1}"/>
</c:comp-filter></c:comp-filter></c:filter>
</c:calendar-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())
+66 -2
View File
@@ -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}
</div>
</section>
<section class="timeline" id="timeline"></section>
<section class="wear">
<section class="wear" id="wear">
<div class="headline" id="headline"></div>
<div class="chips" id="chips"></div>
<div class="note" id="note"></div>
</section>
<section class="events" id="events"></section>
<div id="status"></div>
</main>
@@ -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]) => `<span class="chip"><i style="background:${c}"></i>${l}</span>`).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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 => `<i style="background:${c}"></i>`).join("");
return `<div class="ev${past ? " past" : ""}"><span class="who">${dots}${esc(e.who.join(" & "))}</span>
<span class="when">${when}</span><span class="what">${esc(e.title)}</span></div>`;
});
if (evs.length > shown.length) rows.push(`<div class="ev more">+${evs.length - shown.length} more</div>`);
if (max === 1 && evs.length > 1) return `<div class="ev more">${evs.length} things on the calendar</div>`;
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);
</script>
+28 -4
View File
@@ -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" <<EOF
// Written by install.sh. Re-run it with --lat/--lon to change.
@@ -147,6 +154,15 @@ AWAKE_SECONDS=$AWAKE_SECONDS
OUTPUT='*'
EOF
# calendars.conf is only rewritten when --calendar/--no-calendars is passed
if [ ${#CALENDARS[@]} -gt 0 ]; then
{ echo "# Written by install.sh. Re-run it with --calendar \"Name=URL\" (once per kid) to change."
printf '%s\n' "${CALENDARS[@]}"; } > "$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"