Raspberry Pi running FullPageOS, displaying Google Calendar full-screen on a wall-mounted HDMI display.
At a glance
| Hostname | wallcal |
| IP (LAN) | 10.0.10.103 (DHCP, wlan0) |
| Hardware | Raspberry Pi 3 Model B Rev 1.2 (BCM2835, ~1 GB RAM) |
| Storage | 16 GB SD card (/dev/mmcblk0) |
| Display | HDMI-1, 1920×1080 @ 60 Hz, orientation normal |
| OS | Raspbian GNU/Linux 12 (bookworm), armv7l |
| Base image | FullPageOS / CustomPiOS |
| Primary user | mdbook (sudo, autologin) |
| Timezone | America/New_York |
| Purpose | Display https://calendar.google.com/calendar/u/0/r in Chromium kiosk |
Access
- SSH:
ssh mdbook@wallcal(orssh mdbook@10.0.10.103) — key-based, password-protected sudo - VNC:
wallcal:5900—x11vnc.serviceon display:0, password stored at/opt/custompios/vnc/passwd. Use any VNC client. - Physical: HDMI + USB-C power; SD card is the only persistent storage.
How it boots into kiosk mode
The boot chain:
- systemd → lightdm auto-logs in user
mdbook. Configured in/etc/lightdm/lightdm.conf:autologin-user=mdbook autologin-session=guisession user-session=guisession guisessionis a custom xsession (/usr/share/xsessions/guisession.desktop) that runs/opt/custompios/scripts/start_gui.start_guisets a few X options (no screensaver, no DPMS, optional rotation), launchesmatchbox-window-manager, then runs/opt/custompios/scripts/run_onepageos.run_onepageosultimately invokes/opt/custompios/scripts/start_chromium_browser, which launches Chromium in--kiosk --app=<url>mode.- The URL comes from
/boot/firmware/fullpageos.txt(read by/opt/custompios/scripts/get_url). Currently:https://calendar.google.com/calendar/u/0/r
The --app= flag means Chromium launches as a frameless single-window app — no tabs, no address bar, no chrome.
Chromium kiosk flags (from start_chromium_browser)
--kiosk
--touch-events=enabled
--disable-pinch
--noerrdialogs
--disable-session-crashed-bubble
--simulate-outdated-no-au='Tue, 31 Dec 2099 23:59:59 GMT'
--disable-component-update
--overscroll-history-navigation=0
--disable-features=TranslateUI
--autoplay-policy=no-user-gesture-required
Chromium profile lives at /home/mdbook/.config/chromium/Default/ — that’s where the Google login cookies for Calendar are stored. Do not delete this directory unless you’re prepared to log back into Google on the wall.
Useful operational commands
All of these are FullPageOS-provided helpers in /opt/custompios/scripts/. Erratum (2026-05-21): an earlier version of this page claimed they were also symlinked under /home/mdbook/scripts/. They aren’t — /home/mdbook/scripts/safe_refresh is a separate copy. Worse, safe_refresh is mode 0644 (not executable) in both locations, so you must invoke it via bash. refresh is 0755 and executable directly.
| Command | What it does |
|---|---|
/opt/custompios/scripts/refresh | Sends Ctrl+F5 to the Chromium kiosk window via xdotool. Executable. |
bash /opt/custompios/scripts/safe_refresh | Same, but only if curl --fail against the kiosk URL succeeds — won’t refresh during a network outage. Must be invoked via bash, not executed directly. |
/opt/custompios/scripts/get_url | Prints the kiosk URL |
/opt/custompios/scripts/reload_fullpageos_txt | Re-reads fullpageos.txt and updates the running kiosk |
/opt/custompios/scripts/rotate.sh | Rotates the display |
/opt/custompios/scripts/setX11vncPass | Set the VNC password |
To change the URL the kiosk shows: edit /boot/firmware/fullpageos.txt (you’ll need sudo) and reboot, or run reload_fullpageos_txt.
Both refresh scripts only set DISPLAY=:0 and rely on the invoking user already owning the X session. That means they must run as user mdbook (the autologin user) — running them via sudo as root fails with Authorization required, but no authorization protocol specified. The daily-refresh cron below works around that with runuser -u mdbook --.
Auto-update / patching
A weekly cron runs a full apt-get full-upgrade and reboots the box if a kernel/libc update requires it. This is the system that keeps Chromium current — without it, Google Calendar’s SPA breaks in subtle ways (see “Known gotchas” below).
Files
-
/usr/local/sbin/wallcal-weekly-upgrade— the upgrade script#!/bin/bash set -e exec >> /var/log/wallcal-upgrade.log 2>&1 echo "=== $(date -Iseconds): starting weekly upgrade ===" export DEBIAN_FRONTEND=noninteractive apt-get update apt-get -y \ -o Dpkg::Options::=--force-confold \ -o Dpkg::Options::=--force-confdef \ full-upgrade apt-get -y autoremove --purge echo "=== $(date -Iseconds): upgrade finished ===" if [ -f /var/run/reboot-required ]; then echo "=== reboot required, rebooting in 1 min ===" shutdown -r +1 "wallcal weekly upgrade reboot" fi -
/etc/cron.d/wallcal-weekly-upgrade— runs Sundays at 01:00 localSHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 0 1 * * 0 root flock -n /var/lock/wallcal-upgrade.lock /usr/local/sbin/wallcal-weekly-upgrade -
/etc/logrotate.d/wallcal-upgrade— keeps 6 monthly rotations/var/log/wallcal-upgrade.log { monthly rotate 6 compress missingok notifempty }
Why these specific choices
- Sunday 01:00: low-traffic, post-midnight rollover already done, family is asleep.
flock: prevents stacked invocations if a previous upgrade is somehow still running.--force-confold: keeps existing config files when packages ship new defaults — important because the FullPageOSlightdm.confand X autostart bits would break the kiosk if overwritten.shutdown -r +1(rather thanreboot): broadcasts a 1-minute warning, and is interruptible withshutdown -cif you happen to be SSH’d in.- Reboot only if
/var/run/reboot-required: most weeks no reboot happens — only kernel/libc updates trigger one. set -e: a failedapt-get updatewon’t proceed to a half-applied upgrade.
Manually trigger an upgrade
ssh mdbook@wallcal 'sudo flock -n /var/lock/wallcal-upgrade.lock /usr/local/sbin/wallcal-weekly-upgrade'
tail -f /var/log/wallcal-upgrade.log # or on the box: tail -fIf you want to babysit it interactively (so SSH disconnect doesn’t kill it):
ssh mdbook@wallcal
tmux new -s upgrade
sudo /usr/local/sbin/wallcal-weekly-upgrade
# Ctrl-b d to detach; tmux attach -t upgrade to reattachDisable temporarily
sudo chmod -x /usr/local/sbin/wallcal-weekly-upgrade # cron will still try, fail silently
# or:
sudo rm /etc/cron.d/wallcal-weekly-upgrade # full disableDaily refresh (renderer-staleness workaround)
A daily cron sends Ctrl+F5 to the kiosk at 02:00 local. This works around a separate failure mode from the stale-Chromium bug below: even with Chromium fully current, a GCal renderer that’s been running for days on a Pi 3 eventually wedges its own “today” timer (see “Known gotchas → variant B”). Refreshing daily resets the SPA state cheaply, without restarting Chromium or rebooting the box.
On failure (safe_refresh returns non-zero — most likely because curl --fail against the kiosk URL came back not-OK, i.e. network outage), the script also POSTs an Apprise notification so the silent-failure mode of “cron ran but the refresh actually didn’t happen” doesn’t go undetected.
Files
-
/usr/local/sbin/wallcal-daily-refresh#!/bin/bash set -u exec >> /var/log/wallcal-refresh.log 2>&1 echo "=== $(date -Iseconds): starting daily refresh ===" APPRISE_URL="" [ -r /etc/default/wallcal-refresh ] && . /etc/default/wallcal-refresh # safe_refresh needs the X session, owned by mdbook on display :0. # It is mode 0644 on disk, so invoke via bash rather than executing directly. if runuser -u mdbook -- bash /opt/custompios/scripts/safe_refresh; then rc=0 else rc=$? echo "!!! safe_refresh failed (rc=$rc) at $(date -Iseconds)" if [ -n "$APPRISE_URL" ]; then tail_excerpt=$(tail -20 /var/log/wallcal-refresh.log 2>/dev/null) curl -fsS -m 10 --http1.1 -X POST \ --data-urlencode "title=wallcal daily refresh failed (rc=$rc)" \ --data-urlencode "body=Host: $(hostname) When: $(date -Iseconds) Exit: $rc Recent log: $tail_excerpt" \ "$APPRISE_URL" >/dev/null \ && echo " apprise ping ok" \ || echo " apprise ping itself failed (rc=$?) — but original failure stands" fi fi echo "=== $(date -Iseconds): safe_refresh exit=$rc ===" exit $rc -
/etc/default/wallcal-refresh— secret-ish,root:root 0600# Sourced by /usr/local/sbin/wallcal-daily-refresh. # Failure-only notification target. Empty/unset disables the ping. APPRISE_URL="https://apprise.cloud.mdbook.one/notify/apprise?tag=general" -
/etc/cron.d/wallcal-daily-refresh— runs daily at 02:00 localSHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 0 2 * * * root flock -n /var/lock/wallcal-refresh.lock /usr/local/sbin/wallcal-daily-refresh -
/etc/logrotate.d/wallcal-refresh— keeps 6 monthly rotations/var/log/wallcal-refresh.log { monthly rotate 6 compress missingok notifempty create 0640 root adm }
Why these specific choices
- 02:00 local: well after the weekly upgrade window (Sun 01:00) so the two never overlap, and well after midnight so the refresh lands on the correct new “today”.
runuser -u mdbook --:safe_refreshonly setsDISPLAY=:0; the X cookie lives with themdbookautologin session, so root invocations getAuthorization requiredand fail. Dropping tomdbookworks because that user owns the running X session.safe_refresh(notrefresh): if the network is down at 02:00, we don’t want to clobber the currently-displayed page with a load failure.safe_refresh’scurl --failprecheck means a network blip just skips a day.- Distinct
flockfile from the weekly upgrade: the two jobs are independent and should never block each other. - Apprise endpoint shape
/notify/apprise?tag=general: the cloud-stack Apprise instance stores its config under the literal keyapprise(visible athttps://apprise.cloud.mdbook.one/cfg/apprise). The Discord URL routed bytag=generalis the General Alert webhook. A successful POST returns HTTP 200 with bodySent Discord notification.— note that POSTing to the wrong key returns HTTP 204 with empty body and silently no-ops, so 204 is not confirmation of delivery on this deployment. curl --http1.1: the Caddy-fronted Apprise endpoint tripsHTTP/2 stream not closed cleanly (PROTOCOL_ERROR)on POSTs at HTTP/2. Forcing 1.1 works around it.- Failure-only ping: the calendar is eyeballed daily, so success spam adds noise without value. Only fires when the refresh actually didn’t happen.
|| truesemantics on the apprise ping: if Apprise itself is down, the originalsafe_refreshfailure still propagates as the script’s exit code. The ping is best-effort.
Manually trigger / verify
ssh mdbook@wallcal 'sudo /usr/local/sbin/wallcal-daily-refresh && sudo tail /var/log/wallcal-refresh.log'To smoke-test the apprise failure path without breaking the live kiosk, copy the script and sed in a bogus path:
ssh mdbook@wallcal '
sudo cp /usr/local/sbin/wallcal-daily-refresh /tmp/wcdr-test
sudo sed -i "s|/opt/custompios/scripts/safe_refresh|/opt/custompios/scripts/NONEXISTENT|" /tmp/wcdr-test
sudo chmod +x /tmp/wcdr-test
sudo /tmp/wcdr-test; echo "exit=$?"
sudo rm /tmp/wcdr-test
'You should see a failure ping in the general Discord channel.
Known gotchas
Google Calendar freezes on “today” — variant A: Chromium too old
Symptom: The kiosk shows the date the page was loaded, even after midnight has rolled over. Refreshing the page fixes it temporarily.
Cause: Chromium too far behind upstream. Google Calendar’s SPA quietly stops re-evaluating “today” when its JS hits a feature it expected the browser to support. Observed when the box was running Chromium 130 (Oct 2024) against Calendar code from May 2026.
Fix: Keep Chromium current. The weekly cron above is the long-term solution. Manual one-off if it ever creeps back:
ssh mdbook@wallcal 'sudo apt-get update && sudo apt-get install -y --only-upgrade chromium chromium-common chromium-sandbox chromium-l10n chromium-browser rpi-chromium-mods'Then either reboot or kill the kiosk Chromium so it relaunches with the new binary.
Google Calendar freezes on “today” — variant B: long-lived renderer drift on Pi 3
Symptom: Identical to variant A — date stuck on whatever it loaded with, refresh temporarily fixes it. But Chromium and the kernel are already current, the weekly upgrade ran on schedule, and there’s no half-applied dpkg state.
Cause: The GCal SPA renderer process accumulates degradation when it stays alive for days on a memory-constrained Pi 3. Observed 2026-05-21: kiosk had been up 2 days 4 hours, the GCal renderer was pegged at ~152% CPU and 375 MB RSS (40% of the box’s RAM), the system was actively swapping (~173 MB of swap in use), and load average sat at 2.4 sustained. Under that pressure the JS timer that re-evaluates “today” stops firing reliably. This is not a Chromium-version bug — Chromium 147 was current at the time — it’s a long-running-tab bug specific to GCal on tight hardware.
How to tell variant B from variant A:
ssh mdbook@wallcal 'chromium --version; \
ps -o pid,etime,pcpu,rss -p $(pgrep -f "chromium.*--type=renderer" | head -1); \
free -h; uptime'If Chromium is current and the renderer’s ELAPSED is in days and swap is in use → variant B. If Chromium is months behind upstream → variant A.
Fix: A daily Ctrl+F5 — see “Daily refresh (renderer-staleness workaround)” above. One-off manual refresh: ssh mdbook@wallcal 'bash /opt/custompios/scripts/safe_refresh' (must run as mdbook, not via sudo).
A full reboot or a systemctl restart lightdm also fixes it, but those are heavier than the page reload and shouldn’t be needed routinely.
Half-applied apt upgrades
If an SSH session running apt upgrade is killed (network drop, closed terminal), dpkg can be left mid-transaction and the upgrade silently won’t resume on its own — you’ll just be stuck on whatever was installed before the interruption. This actually happened on this box on 2025-12-07 and went undiagnosed for ~5 months until Google Calendar broke.
Mitigations now in place:
- The weekly cron job runs detached from any TTY (cron orphans it from a session, can’t be SIGHUP’d).
- For manual upgrades over SSH, always run inside
tmux(orscreen, orsystemd-run --no-block). - Recovery if it happens again:
sudo dpkg --configure -a && sudo apt-get -f install && sudo apt-get full-upgrade.
chromium-codecs-ffmpeg-extra is on a different track
The chromium-codecs-ffmpeg-extra package shows up in dpkg -l at version 126.0.6478.164-rpt1 while everything else is 147.x. This is expected — it comes from a different repo (raspbian/raspberrypi non-free) and updates on a slower cadence. Not a problem; do not try to “fix” it.
Memory pressure
Pi 3 has 1 GB RAM and Chromium is hungry. Currently sits around 50% used at fresh boot, drifts to ~60% used with ~170 MB of swap after a couple of days — which is exactly the condition that triggers variant B above. If you ever add other workloads to this box, watch out — running a second Chromium tab or a heavy extension can OOM the kiosk.
Apprise endpoint quirks (cloud stack)
- The Apprise config key on this deployment is the literal string
apprise, visible athttps://apprise.cloud.mdbook.one/cfg/apprise. Configs are loaded into memory at server start. - POSTing to a non-existent key (e.g.
/notify/general,/notify/wallcal) returns HTTP 204 with empty body — looks like success but no notification fires. HTTP 200 withSent Discord notification.in the body is the only proof of delivery. - Use
tag=<tagname>(singular). The plural form is silently ignored. - Apprise behind the Caddy edge sometimes trips HTTP/2
PROTOCOL_ERRORon POSTs.curl --http1.1works around it. - The
generaltag routes to the General Alert Discord webhook. The other config entry isfrontline(Frontline Sniper webhook).
Recovery cheatsheet
| Problem | Try |
|---|---|
| Black screen, network up | ssh mdbook@wallcal sudo systemctl restart lightdm |
| Kiosk frozen but otherwise fine | ssh mdbook@wallcal bash /opt/custompios/scripts/safe_refresh |
| GCal showing wrong day, Chromium current | bash /opt/custompios/scripts/safe_refresh (one-off). Recurring? Check that /etc/cron.d/wallcal-daily-refresh and /var/log/wallcal-refresh.log exist and show recent entries. |
| GCal showing wrong day, Chromium months stale | Fix the upgrade pipeline (variant A above). |
| Apprise failure ping arrived | Investigate within ~24h — the daily refresh skipped at least once. Most likely cause: network blip at 02:00 making safe_refresh’s curl --fail precheck miss. Single misses are self-healing; repeated misses mean something larger broke. |
| Wrong URL on screen | Edit /boot/firmware/fullpageos.txt then reboot |
| Locked out of SSH | Plug in keyboard + monitor (HDMI), Ctrl+Alt+F2 → log in as mdbook |
| Truly broken | Pull the SD card; image is FullPageOS — reflash with fullpageos.com, restore Chromium profile from backup |
Backups
There is currently no scheduled backup of the Chromium profile or system config. The only thing irreplaceable is the Google login cookie state — if the SD card dies, you’ll have to log in to Google on the wall again (which is annoying because there’s no keyboard).
If this becomes a concern, snapshot /home/mdbook/.config/chromium/Default/ periodically.
Change log
- 2026-05-21 (later): Wired Apprise failure-only ping into
wallcal-daily-refresh. New file/etc/default/wallcal-refresh(root:root 0600) holdsAPPRISE_URL. Verified end-to-end: simulatedsafe_refreshfailure → HTTP 200 from Apprise → Discord notification landed in thegeneralchannel. Documented Apprise endpoint quirks (silent 204 on wrong key, HTTP/2 PROTOCOL_ERROR workaround) so future-me doesn’t burn another hour rediscovering them. - 2026-05-21: Diagnosed second occurrence of Calendar-stuck-on-today. This time Chromium was current (147.0.7727.101, weekly upgrade had run as expected on 2026-05-17) but the GCal renderer had been alive 2d4h with sustained 152% CPU / 375 MB RSS, swap in use, load avg 2.4. Treated as a distinct failure mode (variant B). Deployed
/usr/local/sbin/wallcal-daily-refresh+/etc/cron.d/wallcal-daily-refresh(02:00 daily) + logrotate to do asafe_refreshonce a day. Documented errata:/home/mdbook/scripts/safe_refreshis a copy not a symlink, andsafe_refreshis mode 0644 — must be invoked viabash. - 2026-05-09: Diagnosed Calendar-stuck-on-today bug (now classified as variant A). Root cause: Chromium 130 (Oct 2024) — an
apt upgradestarted 2025-12-07 had been interrupted mid-run and the partial state went unnoticed. Ran fullapt-get full-upgrade(Chromium → 147.0.7727.101, kernel → 6.12.75, ~150 packages), rebooted. Deployed/usr/local/sbin/wallcal-weekly-upgrade+ cron + logrotate to prevent recurrence.