diff --git a/docs/explanations/2026-07-20-explanation-phone-driven-alarm-timer.html b/docs/explanations/2026-07-20-explanation-phone-driven-alarm-timer.html new file mode 100644 index 0000000..6e4c8e6 --- /dev/null +++ b/docs/explanations/2026-07-20-explanation-phone-driven-alarm-timer.html @@ -0,0 +1,764 @@ + + +
+ + +wake-alarm · commits 87d2629 → 997a1c7 · 2026-07-20
wake_alarm is a personal alarm clock that runs on a Linux desktop. When it
+fires, it opens a fullscreen Tk window you cannot dismiss without typing several
+randomly-generated codes, ramps the case fans to 100%, forces the monitor on over DDC/CI,
+and plays audio through whatever sound sink happens to exist at that moment. The point is
+to be genuinely hard to ignore.
Two facts about its surroundings matter for this change.
+ +The alarm time comes from a phone. A small Flutter app
+(phone_app/) lets you pick a time and pushes it to a private GitHub repo,
+kuhyx/syncs, as a CRDT log at
+wake-alarm-sync/devices/phone/alarm.json. GitHub here is not a git remote in
+any interesting sense — it is dumb file storage behind the REST Contents API. The PC reads
+that one file back. The stored shape is a record whose fields are
+(value, hybrid-logical-clock) pairs, so the hour lives at
+.alarm.fields.alarm_hour[0].
The alarm does not run alone. A separate program,
+screen_locker, locks the screen until you have logged a workout. To stop the
+two fullscreen windows fighting each other, a third component —
+morning-routine.service, which lives in yet another repository — runs them as
+sequential subprocesses: alarm first, workout lock after. Nothing should ever start
+wake-alarm.service directly; going through the orchestrator is what keeps the
+workout lock in the picture.
The old design assumed the machine is off at night. That single assumption did +all the work:
+ +shutdown-wrapper.sh was installed at /usr/local/bin/shutdown,
+ which shadows /usr/bin/shutdown because /usr/local/bin comes
+ first in PATH. Whenever you shut down, it intercepted the call, read the
+ phone's alarm, and armed the real-time clock with rtcwake before
+ hibernating.systemd-sleep hook
+ (sleep-hook.sh) noticed the resume and started
+ morning-routine.service.Note what that means: the alarm instant was baked into the RTC at shutdown +and could not change afterwards. That was fine when the decision and the event were only +eight hours apart and the machine was inert in between.
+ +Then the premise changed. The PC stopped being shut down at night.
+ With no shutdown there is no rtcwake; with no resume there is no hook. The
+ alarm simply never fired.
There was a second, nastier problem. wake-alarm.service shipped with
+WantedBy=graphical-session.target and install.sh enabled it. Look
+at what actually gated the alarm — _should_run_alarm() in
+_alarm.py:
def _should_run_alarm() -> bool:
+ if not _is_alarm_day(): # every day is an alarm day
+ return False
+ if was_alarm_dismissed_today(): # already dealt with
+ return False
+ if was_workout_logged_today(): # nothing left to enforce
+ return False
+ return True
+
+There is no time condition anywhere in it. Under the old model that was safe
+by accident: the only boot was the rtcwake boot, so "session start" and
+"alarm time" were the same moment. On an always-on machine
+graphical-session.target is reached on every login — so the alarm would fire
+when you logged in, at any hour.
The change replaces one scheduling decision made hours early and frozen with a +cheap decision re-made every minute.
+ +A timer rather than a long-lived daemon matters for two reasons. A resident scheduler +can die silently and nobody notices until a missed morning; each timer tick is a new +process, so there is nothing to keep alive. And a daemon that sleeps until a target time +has to reason about the wall clock jumping when the machine suspends and resumes — a tick +that only ever asks "what time is it now?" never has that problem.
+ +The obvious rule — "fire when now == alarm" — is too brittle. A tick can be
+missed while the machine is suspended, or the machine may boot at 07:10 having been off at
+07:00. So the trigger fires when now falls inside a half-open window that starts
+at the alarm and lasts TRIGGER_CATCHUP_MINUTES (60):
06:40 ≤ now < 07:40. The window is half-open:
+ 07:40 exactly no longer fires.Repeated firing inside that window is not a problem, and this is the neat part: firing
+means systemctl --user start morning-routine.service, and starting an
+already-active unit is a no-op. So while the alarm is on screen the
+following ticks do nothing, and once you dismiss it the
+was_alarm_dismissed_today() gate closes. The design needs no dedup bookkeeping
+of its own — it borrows systemd's and the existing state file's.
A read can fail: no network, an expired token, GitHub down. Failing to wake up is a much
+worse outcome than waking at a slightly wrong time, so get_alarm() degrades
+through four steps rather than raising:
That cache does double duty, and it is the reason the tick can be per-minute while the +network read is only every fifth minute. Firing lands on the exact minute; a phone change +still shows up within about five.
+ +An aside that cost real time. Because this path degrades so
+ gracefully, it also hides breakage. The phone's stored OAuth token survived an app
+ update that changed the requested scope from gist to repo, so
+ every push had been failing for eight days — while the app's settings screen cheerfully
+ showed "Connected to GitHub", because that check only tests that a token
+ exists. Meanwhile both PC readers fell back silently. Nothing looked broken
+ anywhere.
After all this shipped, the alarm still went off at 07:00 on a morning the phone said
+08:00 — and the journal showed no Firing line from the new trigger at all.
+Something else was starting the orchestrator:
It fired at 07:00, ran undismissed for its 30-minute window, and left the unit still
+active — so when the real 08:00 trigger arrived, its systemctl start was a
+no-op against the stale run. Removing that fixed-hour ladder is what actually made "the PC
+follows the phone" true.
This runs the real decision logic from _decide(): the gates are checked
+first and short-circuit, then the window test. Move the times and watch the verdict.
+ + + +
+wake_alarm/_sync.py (new)The whole module is deliberately stdlib-only. The systemd units run
+/usr/bin/python directly rather than a virtualenv, so every third-party import
+is a way for the alarm to die at 06:40 with ModuleNotFoundError. A single HTTP
+GET is not worth that risk, so it uses urllib.request.
The parse deliberately mirrors shutdown-wrapper.sh, which reads the
+same file with jq for the hibernate fallback path. Two readers of one
+format is a drift hazard, so both are pinned to the same field path and documented as a
+pair.
def get_alarm(*, max_cache_age_seconds=ALARM_CACHE_MAX_AGE_SECONDS) -> Alarm:
+ cached = _read_cache()
+ if cached is not None:
+ hour, minute, fetched_at = cached
+ if time.time() - fetched_at < max_cache_age_seconds:
+ return Alarm(hour, minute, "cache") # no network at all
+
+ remote = fetch_remote_alarm()
+ if remote is not None:
+ _write_cache(*remote)
+ return Alarm(remote[0], remote[1], "remote")
+
+ if cached is not None:
+ return Alarm(cached[0], cached[1], "stale-cache")
+
+ return Alarm(FALLBACK_ALARM_HOUR, 0, "fallback")
+
+The source field is not decoration — it is what makes a wrong alarm
+diagnosable at a glance, and the trigger uses it to decide whether to complain.
Every degraded branch above logs at INFO, not WARNING. A
+persistent condition — an unconfigured token, say — logged at warning level once a minute
+is 1440 identical journal lines a day, which trains you to ignore the journal. The single
+warning that survives is emitted by the trigger at the one moment it is actionable:
if alarm.source not in {"remote", "cache"}:
+ _logger.warning(
+ "Firing on a degraded alarm source (%s) — the phone's alarm "
+ "could not be read; check ~/.config/wake_alarm/sync.json",
+ alarm.source,
+ )
+
+That is: I am waking you up right now, at a time that did not come from your +phone.
+ +wake_alarm/_trigger.py (new)Gates first, clock second. The ordering is not cosmetic: it means a tick on an +already-handled day costs two file reads and no network.
+ +def _decide(now: datetime, alarm: Alarm) -> tuple[bool, str]:
+ skip = _skip_reason(now)
+ if skip is not None:
+ return False, skip
+
+ fire_at, window_end = _fire_window(alarm, now)
+ if now < fire_at:
+ return False, f"alarm {alarm} is still ahead"
+ if now >= window_end:
+ return False, f"alarm {alarm} passed over ... — window closed"
+ return True, f"alarm {alarm} is due (since {fire_at:%H:%M})"
+
+Times are local and timezone-aware. The phone stores a wall-clock H:M, so
+the fire instant must be built in the timezone you read off the phone — but carrying the
+offset explicitly keeps comparisons unambiguous. Building it needs one subtlety:
fire_at = (
+ now.replace(tzinfo=None) # drop to wall clock
+ .replace(hour=alarm.hour, minute=alarm.minute, second=0, microsecond=0)
+ .astimezone() # re-derive the offset
+)
+
+Calling .replace(hour=...) straight on an aware datetime keeps
+now's UTC offset. On the night the clocks change, deciding a 06:40 alarm from a
+03:00 tick would then attach the wrong offset. Dropping to naive and re-attaching asks the
+system what the offset is at the target wall time.
wake-alarm.service, install.shThe unit loses its [Install] section entirely and gains a comment
+explaining that this is deliberate. install.sh actively disables it, to clear
+the stale symlink left by older installs:
systemctl --user disable wake-alarm.service 2>/dev/null || true
+
+Both guards matter under set -euo pipefail: a unit with no
+[Install] cannot be enabled, and disabling a never-enabled unit can also
+return non-zero. The unit now survives only as a manual test handle — nothing starts it
+automatically, and the comment says so, so nobody "repairs" the missing auto-start and
+reintroduces the bug.
pyproject.tomlThis one is worth dwelling on because the symptom pointed somewhere else. The installed
+/usr/local/bin/shutdown was stale, which looked like a forgotten reinstall.
+It was not: install.sh had been failing at step 1 ever since the
+Flutter phone_app/ directory was added.
error: Multiple top-level packages discovered in a flat-layout:
+ ['phone_app', 'wake_alarm']
+
+setuptools refuses to guess. Because the script runs under set -euo pipefail
+and does the editable install first, it aborted before installing the units, the sleep
+hook, the fan script or the shutdown wrapper — silently, as far as anyone watching the
+alarm was concerned. The fix is to stop relying on autodiscovery:
[build-system]
+requires = ["setuptools>=61"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+include = ["wake_alarm*"]
+
+Two changes landed elsewhere in the same effort, because the behaviour is only correct +with all three:
+ +| Repo | Change | Why it belongs to this story |
|---|---|---|
testsAndMisc |
+ Removed maybe_start_morning_alarm() from the night-lockdown unlock
+ script |
+ It was the second, phone-independent trigger — the 07:00 fire. Its own comment + called it a workaround for the lost hibernate event. | +
screen-locker |
+ Bounded retry around sync calls; stopped reporting network errors as
+ 403 |
+ The orchestrator starts seconds after boot/resume, before DHCP settles. Every such + run failed and blamed the token, which was fine. | +
Five questions on the reasoning behind the change. Click an option to check it.
+ +Score: 0 / 0
+ + + + + diff --git a/docs/explanations/index.html b/docs/explanations/index.html new file mode 100644 index 0000000..0f7028f --- /dev/null +++ b/docs/explanations/index.html @@ -0,0 +1,43 @@ + + + + + +1 explanation · interactive walkthroughs of notable changes, newest first.
+ + +