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

Quiz

Five questions on the reasoning behind the change. Click an option to check it.

Score: 0 / 0