mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 13:27:32 +01:00
The PC no longer wakes anyone -- the phone does. This deletes the alarm window, the audio escalation, the sink/reactivation machinery, the power-key inhibitor, the escape hatch, the curtains, the smart plug, the brightness and DDC/CI control, the hibernate path (shutdown wrapper + rtcwake + sleep hook) and the challenge set: 30 modules, 34 test files, ~5k lines. What stays is the part that still earns its keep: the per-minute trigger reads the alarm the phone published and starts morning-routine.service, so the workout lock still runs at the right time. THE ONE LOAD-BEARING BEHAVIOUR CHANGE. Deleting _alarm.py orphaned the only writer of wake_state.json, and _trigger gated on it via was_alarm_dismissed_today(). With that gone -- and was_workout_logged_today() already permanently False, since WORKOUT_LOG_FILE does not exist -- both gates would have been dead and the routine restarted on every tick for the rest of the 60-minute catch-up window. The unit is Type=simple, so a start while it runs is a no-op, but the moment the workout lock finished the next tick would relaunch it on top of the user. So _trigger now writes its own once-per-day latch (save_trigger_fired / was_trigger_fired_today) and gates on that. Collapsing the catch-up window instead was rejected: that window is the entire reason a suspended or just-booted machine still fires. It also replaces the deleted sleep hook, via Persistent=true. _trigger also imported should_refire from _alarm_session, which was on the delete list. Since start_session lived only in _alarm.py, no session record is ever written again and the branch was unreachable, not merely unused. WAKE_STATE_FILE is now redirected in tests/conftest.py rather than per-file. The trigger tests never redirected it, because until this commit nothing on that path wrote anything -- and a per-file redirect is exactly the kind that gets forgotten when a new writer appears, with a silent failure: a green suite that quietly stamped the live state file. Verified by mtime that it did not. Cross-repo, same change: morning_routine._orchestrator loses its alarm leg and its --with-alarm flag. Leaving an orchestrator pointing at a deleted module caused a month-long silent failure once already. uninstall.sh is new, and matters more than it looks. /usr/local/bin/shutdown is root-owned and shadows /usr/bin/shutdown; deleting this repo would leave it in place, and with the alarm cache gone it falls through to rtcwake + hibernate -- so "shutdown now" would silently stop being a poweroff, every day, forever. /etc/sudoers.d/wake-alarm is deliberately NOT removed: screen-locker's own _wake_alarm.py still needs passwordless rtcwake. 159 tests pass at 100% branch coverage; all 26 pre-commit hooks green. Verified by running it: _sync --show and _trigger --dry-run both resolve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
210 lines
11 KiB
Markdown
210 lines
11 KiB
Markdown
# CLAUDE.md — wake_alarm
|
|
|
|
## What this does
|
|
|
|
**The phone is the alarm.** `phone_app/` arms and rings it and gates the
|
|
dismissal; this repo's Python half only reads the alarm time the phone
|
|
published and starts the morning routine at that time.
|
|
|
|
The PC's waking role was deleted on 2026-08-30: no alarm window, no audio
|
|
escalation, no smart plug, no curtains, no `rtcwake`, no `shutdown` wrapper,
|
|
no sleep hook. Making the PC wake someone reliably cost more than it returned.
|
|
Everything about it is in git history; do not reintroduce a piece of it
|
|
without saying why the phone cannot do that job.
|
|
|
|
## The dismiss sequence (phone)
|
|
|
|
```
|
|
tag 1 -> 25 steps + 2 min -> tag 2 -> 3 challenges
|
|
```
|
|
|
|
Owned by `phone_app/lib/services/dismiss_gate.dart`. **Strict order, and every
|
|
stage latches** — an unknown or out-of-order tag is ignored, never a reset.
|
|
`room_proof.dart` answers only "did this one check pass just now"; the
|
|
ordering lives in the gate. Keep it that way: the previous version smeared the
|
|
decision across `setState` calls and had a real bug where a wrong tap after
|
|
passing sent the user back to the start.
|
|
|
|
- **Two *distinct* tags.** The same id in both slots is refused at
|
|
registration — one tag satisfying both stages removes the walk, which is the
|
|
entire point. Stored as a JSON list under `roomProof.nfcTagIds`; a legacy
|
|
single `roomProof.nfcTagId` migrates into slot 1.
|
|
- **Steps are unchanged** (`requiredSteps = 25`, `requiredElapsed = 2 min`),
|
|
and are counted from ring start, *not* re-baselined at tag 1. Consequence:
|
|
walking to tag 1 already banks the steps, so only the elapsed floor really
|
|
binds — which is why the steps stage auto-polls instead of offering a button.
|
|
- **Answers are cumulative, not consecutive.** A wrong one costs the attempt
|
|
and nothing else.
|
|
- **The bypass skips the tags, never the walk.** After
|
|
`DismissGate.bypassAfter` (5 min), or immediately when NFC is off or fewer
|
|
than two distinct tags are registered, the tag stages are skipped and the
|
|
count rises to 6. Jumping straight to the challenges would let someone lie in
|
|
bed for five minutes and answer questions without standing up — weaker than
|
|
the gate this replaced. Do not "simplify" it into that.
|
|
- **No challenge may hide its prompt.** The memorise-then-hide kind was
|
|
deleted, not merely unrouted: the ring screen is reached by walking in from
|
|
another room.
|
|
|
|
`RingScreen` takes an injectable `clock`. `Stopwatch` reads the wall clock,
|
|
which `tester.pump()` does not advance, so without that seam no widget test
|
|
can reach the two-minute floor or the five-minute bypass.
|
|
|
|
More, including everything that is a hard crash or a silent no-ring:
|
|
**`DOCS-phone-alarm.md`**.
|
|
|
|
## Scheduling — a per-minute timer
|
|
|
|
`wake-alarm-trigger.timer` ticks every minute (`OnCalendar=*:*:00`,
|
|
`Persistent=true`) and runs `python -m wake_alarm._trigger` — a short,
|
|
stateless check-and-fire. It resolves the alarm via `_sync.get_alarm()`, builds
|
|
today's fire instant in **local** time, and fires iff `now` is in
|
|
`[fire_time, fire_time + TRIGGER_CATCHUP_MINUTES)`. Firing means
|
|
`systemctl --user start morning-routine.service`, which runs the workout lock.
|
|
|
|
**The once-per-day latch is load-bearing.** After a successful start,
|
|
`_trigger` writes `save_trigger_fired()` and `_skip_reason` gates on
|
|
`was_trigger_fired_today()`. Without it the routine restarts on every tick for
|
|
the rest of the 60-minute window: the unit is `Type=simple`, so a start while
|
|
it runs is a no-op, but the moment the workout lock finishes the next tick
|
|
relaunches it on top of the user. This latch replaced the alarm's own
|
|
dismissal record when `_alarm.py` was deleted — deleting one without adding the
|
|
other is a silent regression, and that is exactly the trap.
|
|
|
|
`Persistent=true` plus the 60-minute window is also what replaced the deleted
|
|
systemd-sleep hook: a suspended or just-booted machine still fires.
|
|
|
|
## Reading the phone's alarm
|
|
|
|
`_sync.py` is the only reader.
|
|
|
|
- Source of truth: a `crdt_sync` log written by `phone_app/` under
|
|
`wake-alarm-sync/devices/<device-id>/alarm.json`, at
|
|
`.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`. Same logical path on both
|
|
backends, so only the transport differs, never the parse.
|
|
- **The Firebase reader discovers device directories; it is not pinned to
|
|
`devices/phone/`.** The phone's device id is migrating from the fixed
|
|
constant `phone` to a per-install uuid, and a pinned reader would silently
|
|
stop seeing the alarm and fall back to `FALLBACK_ALARM_HOUR` — i.e. the wrong
|
|
time. `_fetch_firebase_alarm()` lists `SYNC_DEVICES_PREFIX`, reads every
|
|
device's `alarm.json`, and picks the newest by the hour field's **HLC**, with
|
|
`device_id` breaking exact ties. A payload that does not parse is skipped
|
|
rather than allowed to win. `SYNC_ALARM_PATH` remains as a last-resort read
|
|
for a backend that lists nothing.
|
|
- Payload parsing/ranking lives in `_alarm_payload.py`: `_sync.py` owns
|
|
fetching, caching and fallback; `_alarm_payload.py` owns "what did the phone
|
|
actually say".
|
|
- **Firebase first, GitHub as the cutover mirror.** Retiring the mirror is
|
|
deleting the GitHub branch plus `SYNC_API_BASE`/`SYNC_REPO_*`.
|
|
- Firebase config: shared `~/.config/crdt-sync/firebase.json`; the refresh
|
|
token is cached per app under `SYNC_APP_NAME` (`wake_alarm`). The config file
|
|
is checked **before** anything is constructed, so an unconfigured machine
|
|
never touches the network.
|
|
- GitHub token (fallback only): `~/.config/wake_alarm/sync.json` key `token`,
|
|
else a sibling app's raw `~/.config/{diet_guard,screen_locker}/sync_token`.
|
|
Needs `repo` scope — the repo is private, so a token without access returns
|
|
**404, not 403**.
|
|
- `crdt_sync` is imported at module scope but **guarded by
|
|
`try/except ImportError`**: the service runs `/usr/bin/python`, not a venv, so
|
|
a dependency that failed to install must degrade the read to the GitHub path
|
|
rather than crash the per-minute trigger. The GitHub path stays stdlib-only
|
|
for that reason. Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the
|
|
dev venv.
|
|
- Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch → stale
|
|
cache → `FALLBACK_ALARM_HOUR`. The cache bounds remote reads to ~288/day
|
|
despite the per-minute tick.
|
|
- **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute,
|
|
so a persistent condition logged loudly is 1440 identical journal lines a day.
|
|
`_trigger` emits the single WARNING that matters: it is firing *now* on a
|
|
non-phone alarm time.
|
|
|
|
## The morning_routine orchestrator lives elsewhere
|
|
|
|
`morning_routine._orchestrator` (in `testsAndMisc/python_pkg/morning_routine/`)
|
|
is what `_trigger` starts, and it now runs one leg: `screen_locker.screen_lock`.
|
|
Its alarm leg was removed in the same change that deleted `_alarm.py`.
|
|
|
|
**When this package's module paths change, the orchestrator must change in the
|
|
same commit.** Leaving it pointing at a module that no longer exists caused a
|
|
month-long silent production failure once already (2026-05-28, orchestrator
|
|
still on `python_pkg.screen_locker.screen_lock` after screen-locker's
|
|
extraction): the alarm fired and dismissed correctly while the workout lock
|
|
crashed with `ModuleNotFoundError` on every run.
|
|
|
|
## Production dependency installation
|
|
|
|
`wake-alarm-trigger.service` runs `/usr/bin/python` directly — **not** a venv.
|
|
Any new non-stdlib dependency must go into system Python's *user*
|
|
site-packages:
|
|
|
|
```bash
|
|
/usr/bin/python3 -m pip install --user --break-system-packages -e .
|
|
```
|
|
|
|
**Run `install.sh` (or `pip install -e`) from a durable clone**, not a scratch
|
|
directory — it is editable, so a later `git pull` in that clone updates the
|
|
running production code. From `/tmp` or via `pip install "wake_alarm @ git+..."`
|
|
you get a snapshot frozen at that commit, and the next push silently does not
|
|
reach the running service. Convention: `~/wake-alarm`, mirroring
|
|
`~/screen-locker`.
|
|
|
|
If you add a dependency and only install it into a dev venv, the service fails
|
|
with `ModuleNotFoundError` on its next run — this exact gap caused a 3-day
|
|
diet_guard outage. Verify against `/usr/bin/python3 -c "import <dep>"`.
|
|
|
|
## Uninstalling
|
|
|
|
`uninstall.sh`, never `rm -rf` on the repo. `/usr/local/bin/shutdown` is
|
|
root-owned and shadows `/usr/bin/shutdown`; deleting the repo leaves it in
|
|
place, and with the alarm cache gone it falls through to
|
|
`sudo rtcwake … ; exec systemctl hibernate` — so `shutdown now` would silently
|
|
stop being a poweroff, every day, forever.
|
|
|
|
**`/etc/sudoers.d/wake-alarm` is deliberately left in place** even by a full
|
|
uninstall: screen-locker's own `_wake_alarm.py:_schedule_rtcwake` still shells
|
|
out to `sudo rtcwake`, and removing the entry here breaks a live feature in
|
|
another repo with no error message anywhere.
|
|
|
|
## Operational gotchas
|
|
|
|
- **`wake_state.json` is runtime state, not tracked.** It holds the
|
|
HMAC-signed once-per-day latch. It was accidentally committed in the
|
|
monorepo and must stay gitignored here.
|
|
- **`WAKE_STATE_FILE` is redirected in `tests/conftest.py`, not per-file.**
|
|
`_trigger` writes it now, and the trigger tests never redirected it — a
|
|
per-file redirect is exactly the kind that gets forgotten when a new writer
|
|
appears, and the failure is silent: a green suite that quietly stamped the
|
|
live state file.
|
|
|
|
## Commands
|
|
|
|
- Run tests: `python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100`
|
|
- Phone tests: `cd phone_app && flutter test && flutter analyze`
|
|
- Lint: `pre-commit run --all-files`
|
|
- Show the resolved alarm: `python -m wake_alarm._sync --show` (`--no-cache` forces a live read)
|
|
- Show what the next tick would do: `python -m wake_alarm._trigger --dry-run`
|
|
- Watch it live: `journalctl --user -fu wake-alarm-trigger.service`
|
|
- Install: `bash install.sh` — Uninstall: `bash uninstall.sh`
|
|
- Gate the shell/system scripts: `bash scripts/vmbox/harness.sh`
|
|
(disposable Arch VM; see **`DOCS-vmbox.md`**)
|
|
- Ring the phone for a manual test: `lib/main_ring_probe.dart` (see
|
|
`DOCS-phone-alarm.md` — it starts a **real** alarm, and the new sequence is
|
|
what stops it)
|
|
|
|
The dev venv lacks `gatelock`, so anything importing `_state` (including
|
|
`_trigger`) needs `/usr/bin/python3`, not `.venv/bin/python`.
|
|
|
|
## Do NOT
|
|
|
|
- Don't add a dependency without the production install-path check above.
|
|
`_sync.py`'s GitHub path is stdlib-only for that reason — don't "improve" it
|
|
to `requests`, and don't make the `crdt_sync` import unconditional.
|
|
- Don't forget the orchestrator when changing this package's module paths.
|
|
- Don't point the trigger at anything but `morning-routine.service`, or the
|
|
workout lock never runs.
|
|
- Don't remove the trigger's once-per-day latch.
|
|
- Don't raise the log level of `_sync`'s degraded-read paths.
|
|
- Don't let the bypass skip the walk, and don't add a challenge kind that
|
|
hides its own prompt.
|
|
- Don't delete `/etc/sudoers.d/wake-alarm`.
|
|
- Don't commit `wake_state.json`.
|