Raspberry Pi running FullPageOS, displaying Google Calendar full-screen on a wall-mounted HDMI display.

At a glance

Hostnamewallcal
IP (LAN)10.0.10.103 (DHCP, wlan0)
HardwareRaspberry Pi 3 Model B Rev 1.2 (BCM2835, ~1 GB RAM)
Storage16 GB SD card (/dev/mmcblk0)
DisplayHDMI-1, 1920×1080 @ 60 Hz, orientation normal
OSRaspbian GNU/Linux 12 (bookworm), armv7l
Base imageFullPageOS / CustomPiOS
Primary usermdbook (sudo, autologin)
TimezoneAmerica/New_York
PurposeDisplay https://calendar.google.com/calendar/u/0/r in Chromium kiosk

Access

  • SSH: ssh mdbook@wallcal (or ssh mdbook@10.0.10.103) — key-based, password-protected sudo
  • VNC: wallcal:5900x11vnc.service on 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:

  1. systemd → lightdm auto-logs in user mdbook. Configured in /etc/lightdm/lightdm.conf:
    autologin-user=mdbook
    autologin-session=guisession
    user-session=guisession
    
  2. guisession is a custom xsession (/usr/share/xsessions/guisession.desktop) that runs /opt/custompios/scripts/start_gui.
  3. start_gui sets a few X options (no screensaver, no DPMS, optional rotation), launches matchbox-window-manager, then runs /opt/custompios/scripts/run_onepageos.
  4. run_onepageos ultimately invokes /opt/custompios/scripts/start_chromium_browser, which launches Chromium in --kiosk --app=<url> mode.
  5. 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.

CommandWhat it does
/opt/custompios/scripts/refreshSends Ctrl+F5 to the Chromium kiosk window via xdotool. Executable.
bash /opt/custompios/scripts/safe_refreshSame, 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_urlPrints the kiosk URL
/opt/custompios/scripts/reload_fullpageos_txtRe-reads fullpageos.txt and updates the running kiosk
/opt/custompios/scripts/rotate.shRotates the display
/opt/custompios/scripts/setX11vncPassSet 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 local

    SHELL=/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 FullPageOS lightdm.conf and X autostart bits would break the kiosk if overwritten.
  • shutdown -r +1 (rather than reboot): broadcasts a 1-minute warning, and is interruptible with shutdown -c if 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 failed apt-get update won’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 -f

If 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 reattach

Disable 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 disable

Daily 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 local

    SHELL=/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_refresh only sets DISPLAY=:0; the X cookie lives with the mdbook autologin session, so root invocations get Authorization required and fail. Dropping to mdbook works because that user owns the running X session.
  • safe_refresh (not refresh): if the network is down at 02:00, we don’t want to clobber the currently-displayed page with a load failure. safe_refresh’s curl --fail precheck means a network blip just skips a day.
  • Distinct flock file 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 key apprise (visible at https://apprise.cloud.mdbook.one/cfg/apprise). The Discord URL routed by tag=general is the General Alert webhook. A successful POST returns HTTP 200 with body Sent 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 trips HTTP/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.
  • || true semantics on the apprise ping: if Apprise itself is down, the original safe_refresh failure 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 (or screen, or systemd-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 at https://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 with Sent 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_ERROR on POSTs. curl --http1.1 works around it.
  • The general tag routes to the General Alert Discord webhook. The other config entry is frontline (Frontline Sniper webhook).

Recovery cheatsheet

ProblemTry
Black screen, network upssh mdbook@wallcal sudo systemctl restart lightdm
Kiosk frozen but otherwise finessh mdbook@wallcal bash /opt/custompios/scripts/safe_refresh
GCal showing wrong day, Chromium currentbash /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 staleFix the upgrade pipeline (variant A above).
Apprise failure ping arrivedInvestigate 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 screenEdit /boot/firmware/fullpageos.txt then reboot
Locked out of SSHPlug in keyboard + monitor (HDMI), Ctrl+Alt+F2 → log in as mdbook
Truly brokenPull 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) holds APPRISE_URL. Verified end-to-end: simulated safe_refresh failure → HTTP 200 from Apprise → Discord notification landed in the general channel. 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 a safe_refresh once a day. Documented errata: /home/mdbook/scripts/safe_refresh is a copy not a symlink, and safe_refresh is mode 0644 — must be invoked via bash.
  • 2026-05-09: Diagnosed Calendar-stuck-on-today bug (now classified as variant A). Root cause: Chromium 130 (Oct 2024) — an apt upgrade started 2025-12-07 had been interrupted mid-run and the partial state went unnoticed. Ran full apt-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.