# A Raspberry Pi Calendar for the Kitchen Wall We wanted the kind of shared calendar you can glance at without unlocking a phone — an ambient, always-on display in a shared space. So I built one: a small Electron app running fullscreen on a portable monitor driven by a Raspberry Pi. ## What it does It's a fullscreen **week view** that merges several sources into one clean display: - **Google Calendar** — our shared "Jon & Val" calendar - **NurseGrid** — my wife's hospital shift schedule (a webcal feed) - **Apple Reminders** — my timed tasks, via the bridge described below The app is deliberately dumb: it polls a few iCalendar (`.ics`) URLs on an interval, parses them with `node-ical`, expands recurring events for a 30-day window, and re-renders only when something actually changes (a checksum guards against needless redraws). No database, no accounts on the device — just "fetch a URL, draw the week." ## The easy feeds Google Calendar and NurseGrid were trivial because both expose a **shareable ICS/webcal URL**. The Pi just fetches those URLs forever. The content updates upstream; the URL never changes. That's the whole integration. ## The hard feed: Apple Reminders Apple Reminders has no shareable ICS or webcal URL. Reminders live in EventKit/CloudKit and are only reachable from a logged-in Apple device — there's nothing for the Pi to poll. This is the same wall I hit when I first started automating my tasks, which led to [Programmatic Apple Reminders with PyObjC](./programmatic-apple-reminders-with-pyobjc.md). The fix keeps the Pi dumb and puts the work on my Mac (the only machine that can read Reminders): 1. A Python script uses **EventKit (PyObjC)** to read my reminders and emit a standard `reminders.ics` file. 2. A scheduled job (launchd, every few minutes) regenerates that file and **overwrites a single Azure Blob** under a fixed name. 3. The Pi polls that blob's **permanent URL** — exactly like the Google and NurseGrid feeds. ``` Apple Reminders ──(EventKit, launchd)──▶ reminders.ics ──overwrite──▶ Azure Blob (fixed URL) ──poll──▶ Pi ``` The key realization: a stable URL doesn't require a server in front of it. As long as I overwrite the **same** blob name with a long-lived read token, the content changes but the URL is permanent — so the calendar app never needs to be touched when my reminders change. Only reminders with a real **due time** become timed events; date-only and recurring reminders render as all-day so they sit on the day without cluttering the timeline. Each task is prefixed with a ✅ so it reads as a task, not an appointment. ## Polling vs. push The whole system is built on **polling**: the Pi re-fetches each URL on a timer, and the Mac re-publishes on a timer. My first instinct was that polling felt wasteful — wouldn't *push* (publish/subscribe, webhooks, a socket the Pi subscribes to) be more elegant? After living with it, I think polling is the right call here, for a few reasons: - **There's nothing to push from.** Apple Reminders has no public webhook or pub-sub. EventKit can notify a *local* process of changes (`EKEventStoreChanged`), but that signal never leaves the Mac — there's no Apple-hosted event stream the Pi could subscribe to. So push would require *me* to stand up and babysit a notification channel anyway. - **Polling is stateless and self-healing.** If the Pi reboots, loses Wi-Fi for an hour, or the Mac sleeps overnight, nothing needs to reconnect or replay missed events. The next poll just fetches the current truth. A push system has to handle dropped connections, retries, and missed messages — exactly the failure modes an always-on kitchen display will hit. - **The data is small and not urgent.** A 7 KB ICS file fetched once a minute is nothing, and a calendar on the wall doesn't need sub-second freshness. Push earns its complexity when updates are large, frequent, or latency-critical — none of which apply here. The mental model: **push optimizes for latency and bandwidth; polling optimizes for simplicity and resilience.** For an ambient display reading slow-moving data, simplicity and resilience win. The cost is a small, bounded staleness window (a minute or two), which nobody glancing at a wall calendar will ever notice. It's worth naming the constraint that forces the Mac to be awake: the only thing that can read my Reminders is a logged-in Mac session, so the publisher runs as a user **LaunchAgent**, not a system daemon. Neither a LaunchAgent nor a LaunchDaemon runs while the Mac is asleep — but a daemon couldn't read Reminders anyway (no user session at the login window), so the LaunchAgent is the correct and only real choice. When the Mac wakes, the next scheduled run republishes and the Pi catches up on its next poll. ## Why not just write reminders into a calendar? I considered pushing reminders into a Google Calendar that's already in the feed. But I'd rather keep reminders *as reminders* — they have their own lifecycle (completion, recurrence, lists). Publishing a read-only ICS view is non-destructive: the Pi sees them, but the source of truth stays in Apple Reminders. This pairs nicely with my [start-my-day workflow](./programmatic-apple-reminders-with-pyobjc.md), which assigns sensible times to today's tasks so they land cleanly on the wall display. ## Takeaway Ambient displays are at their best when the device is a dumb renderer and all the intelligence lives upstream. Calendars make this easy by speaking ICS. For the one source that doesn't — Apple Reminders — a tiny Mac-side bridge that publishes an ICS to a fixed URL closes the gap without giving the Pi any special privileges. ## Related - [Programmatic Apple Reminders with PyObjC](./programmatic-apple-reminders-with-pyobjc.md) - [Reminder Health: What My Apple Reminders Data Says](./reminder-health-dashboard.md) - [Smart Home Dashboards and Domestic Control Planes](./smart-home-dashboards-and-domestic-control-planes.md)