The PC has been firing the morning routine at 07:00 instead of 08:00, off a
record the phone published on 2026-08-09 and never superseded. The phone was
connected to Firebase the whole time and the home screen still said "Connect a
sync backend in Settings first."
initState fired three futures concurrently and the last one read what the
first two were still writing:
unawaited(_applyPolicy()); // sets _hour/_minute
unawaited(_loadSyncState()); // sets _service
unawaited(_syncToPc()); // reads BOTH
`publish` takes the service as a *parameter*, so it captured `_service` by
value while it was still null. Assigning it moments later could never reach
the call. The startup publish therefore passed null every time and bailed out
before touching the network -- and `_syncToPc` had exactly one call site, that
one, so nothing published, ever. `_applyPolicy` has the same shape: a publish
that did win the race would have sent the baseline instead of an active
override. The bootstrap is now strictly sequential.
Three things conspired to hide it for three weeks:
- `publish` returned "Connect a sync backend in Settings first." for BOTH a
missing backend and a null service, so a broken store looked like a setup
step the user had never done. The advice pointed at something that was not
broken. The two causes now report differently, with a regression test.
- `_loadSyncState` swallowed its exception with `on Exception catch (_)`, so
if the store genuinely failed to open there was no trace anywhere. It logs
now.
- `_pickEarlier` never published at all, so "Wake me earlier tomorrow" -- the
one schedule edit the app offers -- never reached the PC either. It does now.
home_screen.dart went over the 250-line cap, so its body moves to
`screens/home_body.dart` as a stateless widget taking plain values. The screen
keeps the state and the ordering rules, which is where the bugs lived.
Verified on the Pixel 6a: home screen now reads "Synced to PC ✓", and the PC's
`_sync --show --no-cache` returns `08:00 (remote)` stamped seconds earlier,
against `07:00` from 2026-08-09 before. The phone had also minted a new device
id, so both records exist in Firebase and the HLC ranking picks the newer --
which is exactly why that reader ranks by HLC and not by listing order.
104 Dart tests pass; analyzer clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
|
||
|---|---|---|
| .github/workflows | ||
| docs/explanations | ||
| phone_app | ||
| prompts | ||
| scripts | ||
| wake_alarm | ||
| .fvmrc | ||
| .gitignore | ||
| .pre-commit-config.yaml | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| DOCS-phone-alarm.md | ||
| DOCS-vmbox.md | ||
| install.sh | ||
| pyproject.toml | ||
| README.md | ||
| requirements.txt | ||
| TODO-file-length-250.md | ||
| TODO-hardening-continue.md | ||
| uninstall.sh | ||
| wake-alarm-trigger.service | ||
| wake-alarm-trigger.timer | ||
wake_alarm
The phone is the alarm. phone_app/ arms and rings it, and getting past it
means walking between two NFC tags and answering three questions. This repo's
Python half no longer wakes anyone: all it does is read the alarm time the
phone published and start the morning routine (the workout lock) at that time.
The dismiss sequence
Strict, ordered, and latched — every stage required, and a stage once passed never reverts:
tag 1 -> 25 steps + 2 min -> tag 2 -> 3 challenges
The two tags must be distinct and live in different rooms; the same id in both slots is refused at registration. Answers are cumulative, so a wrong one costs the attempt and nothing else.
One fallback, deliberately narrow. After 5 minutes of ringing — or immediately if NFC is off or fewer than two tags are registered — the two tag stages are skipped and the challenge count rises to 6. The walk is never skipped. Without a hatch of some kind, a dead tag at 06:00 leaves an alarm that cannot be switched off at all.
Details, and the things that are a hard crash or a silent no-ring:
DOCS-phone-alarm.md.
The PC half
A per-minute systemd user timer (wake-alarm-trigger.timer) reads the alarm
the phone published through the shared crdt_sync transport, and at that time
starts morning-routine.service, which runs the workout lock. It records a
once-per-day latch so the rest of the catch-up window does not restart it.
That is all it does. There is no alarm window, no audio, no smart plug, no
rtcwake, and no shutdown wrapper.
Install
bash install.sh # trigger timer
bash uninstall.sh # removes it, and any retired waking machinery
install.sh installs the package into system Python's user site-packages (the
units run /usr/bin/python directly, not a venv — see CLAUDE.md) and enables
the timer. It also clears the retired pieces an older install left behind,
notably /usr/local/bin/shutdown, which is root-owned and would otherwise keep
turning shutdown now into a hibernate forever.
Reading the phone's alarm uses the shared ~/.config/crdt-sync/firebase.json.
The GitHub mirror (fallback only) needs a repo-scoped token at
~/.config/wake_alarm/sync.json as {"token": "..."}.
Usage
python -m wake_alarm._sync --show # what alarm time the PC resolves
python -m wake_alarm._trigger --dry-run # what the next tick would do
journalctl --user -fu wake-alarm-trigger.service # watch it live
Development
python -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/pre-commit install && .venv/bin/pre-commit install --hook-type pre-push
.venv/bin/python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100
cd phone_app && flutter test && flutter analyze
The dev venv lacks gatelock, so anything importing _state (including
_trigger) needs /usr/bin/python3, not .venv/bin/python.
See CLAUDE.md for the sync resolution chain, the cross-repo coupling to
morning_routine, and production deployment gotchas.