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 @@ + + + + + +Phone-driven wake alarm on an always-on PC + + + + +

Following the phone: turning a hibernate-scheduled alarm into a per-minute timer

+

wake-alarm · commits 87d2629 → 997a1c7 · 2026-07-20

+ + + +

Background — what this thing is

+ +

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.

+ +
+
+
phone apppick 06:40
+
→
+
kuhyx/syncsalarm.json
+
→
+
PC reader_sync.py
+
→
+
orchestratoralarm → lock
+
+
The alarm time's journey. The PC is a read-only consumer; the phone is the + only writer.
+
+ +

How scheduling used to work

+ +

The old design assumed the machine is off at night. That single assumption did +all the work:

+ + + +

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.

+
+ +

The bug hiding behind that

+ +

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.

+ +

Intuition — the essence of the change

+ +

The change replaces one scheduling decision made hours early and frozen with a +cheap decision re-made every minute.

+ +
+
+
+
before21:30
+
shutdown intercepted → read alarm → arm RTC for 06:40
+
→
+
frozen: phone changes are now invisible
+
+
+
afterevery :00
+
tick → resolve alarm → is now inside the window?
+
→
+
a phone change lands within ~5 min
+
+
+
The old path decided once; the new path re-decides 1440 times a day. Each + tick is a fresh process that exits in milliseconds.
+
+ +

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 fire window, not the fire instant

+ +

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):

+ +
+
+
+
+
+
+
05:00alarm 06:4007:4009:00
+
+
Fires while 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.

+ +

Never trusting the network with your morning

+ +

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:

+ +
+
+
fresh cache< 5 min old
+
→
+
live fetchGitHub
+
→
+
stale cacheany age
+
→
+
13:00fallback
+
+
Each arrow is "the previous step was unavailable". The cache is also what + keeps the per-minute tick from making 1440 GitHub requests a day — only ~288.
+
+ +

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.

+
+ +

The part the diff alone does not show

+ +

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:

+ +
+
+
+
night-lockdown05:00 · 05:15 · 05:30 · 06:00 · 07:00
+
→
+
morning-routine.service
+
+
+
wake-alarm-triggerevery minute, phone's time
+
→
+
morning-routine.service
+
+
+
Two independent triggers, one of which ignored the phone. The fixed ladder + was itself a workaround for the lost hibernate event — the exact problem the new timer + solves.
+
+ +

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.

+ +

Play with it — the fire-window simulator

+ +

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.

+ +
+

Would this tick fire?

+
+ + + + + +
+
+
+
+
+
+
00:0006:0012:0018:0024:00
+
+
+

+ + + +

+
+ +

Code — a walkthrough

+ +

1. Reading the phone's alarm — 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.

+ +

2. Log levels, because this runs 1440 times a day

+ +

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.

+ +

3. The tick — 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.

+ +

4. Closing the login-fire hole — wake-alarm.service, install.sh

+ +

The 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.

+ +

5. The installer had been silently broken — pyproject.toml

+ +

This 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*"]
+ +

6. Beyond this repo

+ +

Two changes landed elsewhere in the same effort, because the behaviour is only correct +with all three:

+ + + + + + + + + + + + + +
RepoChangeWhy it belongs to this story
testsAndMiscRemoved maybe_start_morning_alarm() from the night-lockdown unlock + scriptIt was the second, phone-independent trigger — the 07:00 fire. Its own comment + called it a workaround for the lost hibernate event.
screen-lockerBounded retry around sync calls; stopped reporting network errors as + 403The orchestrator starts seconds after boot/resume, before DHCP settles. Every such + run failed and blamed the token, which was fine.
+ +

Quiz

+

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 @@ + + + + + +wake-alarm — change explanations + + + +
+
wake-alarm
+

Change explanations

+

1 explanation · interactive walkthroughs of notable changes, newest first.

+
+ + 2026-07-20 + Phone-driven wake alarm on an always-on PC + +
+ +
+ +