wake-alarm/shutdown-wrapper.sh
Krzysztof kuhy Rudnicki 66bc3a9e55
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Read the phone's alarm from Firebase, keeping GitHub as the mirror
The seventh and last app on the GitHub-repo transport. fetch_remote_alarm()
is now `_fetch_firebase_alarm() or _fetch_github_alarm()`, so retiring the
mirror later is deleting one branch and three constants.

Two things made this look harder than it was. The first was the standing
"_sync.py is stdlib-only" rule: crdt_sync pulls in requests, and the service
runs /usr/bin/python rather than a venv. But the package already depends on
gatelock and python-kasa, and _smart_plug.py already has the pattern for
exactly this -- a module-scope import inside try/except ImportError. So the
Firebase import is guarded the same way: a dependency that failed to install
degrades the read to GitHub instead of killing the per-minute trigger. The
GitHub path stays stdlib-only, which is what makes that fallback trustworthy.

The second was the shutdown wrapper, which used to do its own authenticated
GitHub read. It could not have worked against Firebase -- an ID token lasts
about an hour and a shell script at shutdown time has no safe way to refresh
one. It now reads the local alarm cache the trigger already keeps fresh
(deployed to /usr/local/bin/shutdown), so the backend is invisible to it.
install.sh also now refuses to run under sudo, which silently skipped its
later steps.

Verified against the live database, not just in tests: the phone's alarm is
already published at wake-alarm-sync/devices/phone/alarm.json, and Firebase
and GitHub both resolve it to 07:45. `_sync --show --no-cache` reports
"07:45 (remote)" and the trigger dry-run behaves unchanged.

338 tests at 100% branch coverage, no new lint suppressions. The backend
tests moved to test_sync_backends.py to keep both files under the 500-line
cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
2026-08-05 09:09:22 +02:00

114 lines
4.8 KiB
Bash
Executable File

#!/bin/bash
# Wrapper for /usr/bin/shutdown that redirects to rtcwake -m disk on alarm
# nights (every day of the week). This ensures that both the automated
# systemd timer AND manual "shutdown now" hibernate correctly so the PC
# wakes for the morning alarm.
#
# Install to /usr/local/bin/shutdown (takes priority over /usr/bin/shutdown
# because /usr/local/bin appears first in PATH).
#
# This is the FALLBACK path, for the rare night the PC is actually powered
# down. The primary path is wake-alarm-trigger.timer, which ticks once a
# minute on the always-on machine and fires at the phone's alarm time without
# any rtcwake involvement (see wake_alarm/_trigger.py).
#
# Wake time is read from ~/.config/wake_alarm/alarm_cache.json, the on-disk
# cache that wake-alarm-trigger.timer refreshes every minute from the alarm
# the phone app pushes. Falls back to WAKE_AFTER_HOURS if that cache is
# missing or unusable.
#
# Deliberately a *local file read*, with no network call and no credentials.
# This script runs as /usr/local/bin/shutdown, at the one moment the machine
# is going down; a remote fetch there can block on a dying network, and under
# Firebase it could not work at all, because an ID token lasts about an hour
# and refreshing one is beyond what a shell script should be doing with a
# secret. The Python side already holds the credentials and already keeps this
# cache fresh, so the backend it syncs against is now invisible here: this
# wrapper needs no changes when GitHub is retired.
#
# The trade-off is bounded and acceptable: a cache older than the trigger's
# refresh interval still reflects the last alarm the phone published, and the
# alarm changes far more rarely than once a minute. MAX_CACHE_AGE_SECONDS
# below only rejects a cache so old it suggests the trigger has been dead for
# days, in which case the fixed fallback is the safer answer.
set -euo pipefail
REAL_SHUTDOWN=/usr/bin/shutdown
RTCWAKE=/usr/sbin/rtcwake
# Fallback: how many hours after shutdown to wake when Gist is unavailable.
# Must match WAKE_AFTER_HOURS in wake_alarm/_constants.py.
WAKE_AFTER_HOURS=8
# Pass through reboots and cancel commands unchanged.
for arg in "$@"; do
case "$arg" in
-r|--reboot|-c|--cancel)
exec "$REAL_SHUTDOWN" "$@"
;;
esac
done
ALARM_CACHE="$HOME/.config/wake_alarm/alarm_cache.json"
# Reject a cache this old as evidence the trigger itself has stopped running.
# Generous on purpose (2 days): the alarm rarely changes, so a merely stale
# cache is still a far better answer than the fixed +8h fallback. Mirrors
# ALARM_CACHE_FILE in wake_alarm/_constants.py.
MAX_CACHE_AGE_SECONDS=$(( 2 * 24 * 3600 ))
# Read the alarm the phone published, from the local cache the trigger keeps
# fresh. Returns the Unix epoch (seconds) of the next occurrence of that H:M.
# Exits non-zero if the cache is absent, malformed, or older than
# MAX_CACHE_AGE_SECONDS, so the caller can fall back.
#
# The cache is written by wake_alarm/_sync.py as
# {"hour": H, "minute": M, "fetched_at": <unix seconds>} — keep this reader in
# step with _write_cache() there. Storing H/M rather than the raw crdt_sync log
# means this script no longer needs to understand the sync payload format at
# all, which is what makes it backend-agnostic.
get_synced_alarm_epoch() {
local hour minute fetched_at now_epoch age target_epoch
[[ -f "$ALARM_CACHE" ]] || return 1
# One jq call for all three fields: -e fails on null, and the fixed field
# order lets a single read destructure them.
read -r hour minute fetched_at < <(
jq -er '"\(.hour) \(.minute) \(.fetched_at)"' "$ALARM_CACHE" 2>/dev/null
) || return 1
[[ "$hour" =~ ^[0-9]+$ && "$minute" =~ ^[0-9]+$ ]] || return 1
(( hour <= 23 && minute <= 59 )) || return 1
printf -v now_epoch '%(%s)T' -1
# fetched_at is a float; strip the fraction rather than pulling in bc.
age=$(( now_epoch - ${fetched_at%%.*} ))
(( age >= 0 && age <= MAX_CACHE_AGE_SECONDS )) || return 1
target_epoch=$(date -d "today $hour:$minute" +%s)
if (( target_epoch <= now_epoch )); then
target_epoch=$(date -d "tomorrow $hour:$minute" +%s)
fi
echo "$target_epoch"
}
# Every day of the week is an alarm day (date +%u: Mon=1 … Sun=7).
tomorrow_dow=$(date -d "tomorrow" +%u)
case "$tomorrow_dow" in
1|2|3|4|5|6|7)
if wake_epoch=$(get_synced_alarm_epoch 2>/dev/null); then
logger -t shutdown-wrapper \
"alarm cache: waking at $(date -d "@$wake_epoch") (epoch $wake_epoch)"
else
wake_epoch=$(( $(printf '%(%s)T' -1) + WAKE_AFTER_HOURS * 3600 ))
logger -t shutdown-wrapper \
"alarm cache unusable — fallback +${WAKE_AFTER_HOURS}h (epoch $wake_epoch)"
fi
sudo "$RTCWAKE" -m no -t "$wake_epoch"
exec /usr/bin/systemctl hibernate
;;
*)
exec "$REAL_SHUTDOWN" "$@"
;;
esac