# CLAUDE.md — wake_alarm ## What this does A wake alarm: on alarm days (`ALARM_DAYS` in `_constants.py` — currently **every day**, `frozenset({0..6})`), the machine hibernates overnight and wakes itself via `rtcwake` at the configured alarm time. `wake-alarm.service` then opens a fullscreen Tk window that must be dismissed (with a typed challenge — see `_challenges.py`), ramps fans to 100% via `wake-alarm-fans.sh`, plays audio through whatever sink comes up after the monitor wakes, and optionally toggles a TP-Link Tapo P110 smart plug via `python-kasa`. ## Scheduling — a per-minute timer, with hibernate as the fallback The PC is **always on**. The alarm time comes from the phone, which can change it at any hour, so nothing about the schedule may be baked in ahead of time. **Primary path — `wake-alarm-trigger.timer`:** 1. The timer ticks every minute (`OnCalendar=*:*:00`, `Persistent=true`) and runs `python -m wake_alarm._trigger` — a short, stateless check-and-fire. 2. `_trigger` 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)`. The catch-up window is what makes a suspended or just-booted machine still fire. 3. Firing = `systemctl --user start morning-routine.service`. **Never** `wake-alarm.service` directly — see the orchestrator section below. Starting an already-active unit is a no-op, so an alarm on screen needs no extra dedupe state; the `was_alarm_dismissed_today()` / `was_workout_logged_today()` gates cover the rest. A timer, not a resident daemon: each tick is a fresh process, so there is no long-lived scheduler to die silently and no wall-clock jump to handle across suspend/resume. **Fallback path — hibernate (only when the PC really is powered down):** `shutdown-wrapper.sh` (installed to `/usr/local/bin/shutdown`, shadowing `/usr/bin/shutdown` via PATH order) still intercepts shutdown/poweroff, reads the same synced alarm, and sets `rtcwake`. `sleep-hook.sh` (in `/usr/lib/systemd/system-sleep/`) fires on resume (`$1 == post`) and starts `morning-routine.service`. This is now a backstop, not the main mechanism. If you change `WAKE_AFTER_HOURS` in `_constants.py`, you must also update the duplicate constant in `shutdown-wrapper.sh` (`WAKE_AFTER_HOURS=8`) — they are not wired together, by design (the shell wrapper has no Python runtime available at the point it intercepts `shutdown`). **The wrapper no longer talks to the network or handles a token.** It reads `~/.config/wake_alarm/alarm_cache.json` — the cache `_sync.py` already refreshes every minute — and falls back to `WAKE_AFTER_HOURS` if that file is absent, malformed, or more than 2 days old. This removed the duplicated token search order (`read_sync_token()` used to mirror `SYNC_CONFIG_FILE` / `SYNC_TOKEN_FALLBACK_FILES`), and it is what makes the wrapper survive the move to Firebase unchanged: an ID token expires in about an hour, and a shell script at shutdown time has no safe way to refresh one. The only coupling left is the cache's `{"hour","minute","fetched_at"}` shape — keep the reader in the wrapper in step with `_write_cache()` in `_sync.py`. ## `wake-alarm.service` must never auto-start `wake-alarm.service` deliberately has **no `[Install]` section** and nothing starts it automatically. `_should_run_alarm()` gates on day / dismissed / workout-logged but has **no time gate**, so a `WantedBy=graphical-session.target` there fires the alarm at *login* time on any alarm day — which is every day now that the machine no longer reboots into the alarm. `install.sh` explicitly runs `systemctl --user disable wake-alarm.service` to clear the stale symlink from older installs. The unit survives only as a manual test handle. Do not "fix" its missing auto-start. ## Reading the phone's alarm `_sync.py` is the only reader of the remote alarm. `shutdown-wrapper.sh` reads `_sync.py`'s local cache instead of the remote file, so there is no second remote reader to keep in step. - Source of truth: a `crdt_sync` log written by `phone_app/` under `wake-alarm-sync/devices//alarm.json`. The value lives at `.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`. **The same logical path on both backends** — Firebase's namespace mirrors the repo layout — 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 reader pinned to the old path would silently stop seeing the alarm the moment the phone migrated — then fall back to `FALLBACK_ALARM_HOUR`, i.e. wake at the wrong time. So `_fetch_firebase_alarm()` lists `SYNC_DEVICES_PREFIX`, reads every device's `alarm.json`, and picks the newest by the hour field's **HLC** (not by listing order), with `device_id` breaking exact ties so two devices stamped in the same millisecond don't alternate between ticks. A device whose payload doesn't parse is skipped rather than allowed to win. `SYNC_ALARM_PATH` remains as a last-resort read for a backend that lists nothing — keep it until every install has migrated. The **GitHub** fallback still reads the single fixed path; it is the mirror, not the primary. - Payload parsing/ranking lives in `_alarm_payload.py`, split out of `_sync.py` so the wire format is testable without network or disk (and to keep both files under the 500-line cap). `_sync.py` owns fetching, caching and fallback; `_alarm_payload.py` owns "what did the phone actually say". - **Firebase first, GitHub as the cutover mirror** (`fetch_remote_alarm()` is just `_fetch_firebase_alarm() or _fetch_github_alarm()`). Retiring the mirror is deleting the GitHub branch plus `SYNC_API_BASE`/`SYNC_REPO_*`. - Firebase config: the shared `~/.config/crdt-sync/firebase.json`; the refresh token is cached per app under `SYNC_APP_NAME` (`wake_alarm`), so this app's credential is separate from its siblings'. The config file is checked **before** anything is constructed, so an unconfigured machine never touches the network. - GitHub token (fallback path 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, and a token without access returns **404, not 403**. - `crdt_sync` is imported at module scope but **guarded by `try/except ImportError`**, exactly like `python-kasa` in `_smart_plug.py`: 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 (`~/.config/wake_alarm/alarm_cache.json`) is what bounds remote reads to ~288/day despite the per-minute tick, and what keeps a network blip from costing a wake-up. - **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. Check what the PC currently resolves: `python -m wake_alarm._sync --show`. Check what it would do right now: `python -m wake_alarm._trigger --dry-run`. ## Cross-repo coupling — not a bug `_constants.py`'s `WORKOUT_LOG_FILE` points at `~/screen-locker/screen_locker/workout_log.json` — a file owned by the separate, already-standalone `screen-locker` repo (https://github.com/kuhyx/screen-locker). This is intentional: the alarm reads whether today's workout was already logged by screen-locker to decide whether the morning routine should also lock the workout screen. If this path ever raises `ModuleNotFoundError`-style confusion, the bug is almost certainly in the **orchestrator** (`morning_routine` in testsAndMisc), not here — see the next section. ## The morning_routine orchestrator lives elsewhere `morning_routine._orchestrator` (in `testsAndMisc/python_pkg/morning_routine/`) runs this package and `screen_locker` as two sequential subprocesses. When either package is extracted to its own repo, **the orchestrator's module reference must be updated in the same change** — this exact mistake (orchestrator left pointing at `python_pkg.screen_locker.screen_lock` after screen-locker's extraction on 2026-05-28) caused a month-long silent production failure where the alarm fired and dismissed correctly but the workout lock crashed with `ModuleNotFoundError` on every run. Once both `wake_alarm` and `screen_locker` are pip-installed system-wide, the orchestrator needs no `PYTHONPATH`/`cwd` plumbing — plain `subprocess.run([sys.executable, "-m", module, "--production"])` resolves both. ## Production dependency installation — read this before adding any dependency `wake-alarm.service` runs `/usr/bin/python` directly — **not** a venv. Any new non-stdlib dependency (this package itself, `gatelock`, `python-kasa`, anything added later) must be installed into system Python's *user* site-packages: ```bash /usr/bin/python3 -m pip install --user --break-system-packages -e . ``` **Always run `install.sh` (or `pip install -e`) from a durable clone, not a scratch directory.** `install.sh` does `pip install -e "$REPO_DIR"` — editable, so a later `git pull` in that same clone updates the running production code with no reinstall needed. The clone must live somewhere permanent (this repo's convention: `~/wake-alarm`, mirroring `~/screen-locker` for the screen-locker package) — if you `pip install -e` from `/tmp/...` or run `pip install "wake_alarm @ git+https://..."` as a one-off, you get a non-editable snapshot frozen at that commit, and the next `git push` here silently does **not** reach the running service. `install.sh` already does this. **If you add a dependency and only install it into a dev venv, the production service will silently fail with `ModuleNotFoundError` on its next run** — this exact gap caused a 3-day diet_guard production outage (2026-06-19 to 2026-06-22) for the sibling `gatelock` migration. Always verify against `/usr/bin/python3 -c "import "`, not just the dev venv, before considering a dependency change done. ## Operational gotchas - **`python-kasa` is optional at runtime** (`_smart_plug.py` catches `ImportError` and disables smart-plug control with a warning log), but it *is* a hard dependency for this repo's own tooling (mypy/pylint/tests need it importable) — see `pyproject.toml`/`requirements.txt`. - **The `wave` module needs special pylint handling.** `_audio.py` opens WAV files in write mode; pylint's stdlib stub infers the read-mode overload and wrongly flags `setnchannels`/`setsampwidth`/`setframerate`/`writeframes` as missing. See the `generated-members` list in `pyproject.toml`'s `[tool.pylint.typecheck]` — don't remove it if pylint starts complaining about `wave.Wave_write`. - **`wake_state.json` is runtime state, not tracked.** It holds the HMAC-signed dismissal record for the current alarm day. It used to be accidentally committed in the monorepo; it is gitignored here and must stay that way. ## Commands - Run tests: `python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100` - Lint: `pre-commit run --all-files` - Test the lock manually (safe, closeable): `python -m wake_alarm._alarm --demo` - Show the resolved alarm: `python -m wake_alarm._sync --show` (add `--no-cache` to force a live GitHub 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 for production: `bash install.sh` Note the dev venv lacks `gatelock`, so anything importing `_state` (including `_trigger`) must be run with `/usr/bin/python3`, not `.venv/bin/python`. ## Do NOT - Don't add a dependency without doing the production install-path check above. `_sync.py`'s **GitHub** path is stdlib-only for exactly this reason — don't "improve" it to `requests`, and don't make the `crdt_sync` import unconditional: the `try/except ImportError` guard is what keeps a failed install from killing the trigger. - Don't forget the orchestrator when changing this package's module path or invocation — see "The morning_routine orchestrator lives elsewhere" above. - Don't give `wake-alarm.service` an `[Install]` section or enable it. It fires at login time, not alarm time — see the section above. - Don't make the trigger start `wake-alarm.service`; it must start `morning-routine.service` so the workout lock still runs. - Don't raise the log level of the routine degraded-read paths in `_sync` — the per-minute tick turns any WARNING there into journal spam. - Don't commit `wake_state.json`.