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