mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
The PC is no longer shut down at night, which broke the scheduling model outright. The wake time used to be decided once, at hibernate, and baked into the RTC by shutdown-wrapper.sh. With no shutdown there is no resume, so sleep-hook.sh never ran and nothing fired the alarm at all. Worse, wake-alarm.service was WantedBy=graphical-session.target and enabled by install.sh, while _should_run_alarm() gates on day / dismissed / workout-logged but has no *time* gate. On an always-on machine that target is reached on every login, so the alarm fired at login time on any alarm day — which is every day. Replace both with a systemd user timer ticking once a minute: - _sync.py reads the alarm the phone pushed to kuhyx/syncs, mirroring shutdown-wrapper.sh's jq parse so the two readers cannot drift. Caches each read, then degrades fresh cache -> fetch -> stale cache -> FALLBACK_ALARM_HOUR, so a network blip can never cost a wake-up. stdlib-only: the units run /usr/bin/python, so a dependency here is a production install-path hazard. - _trigger.py fires morning-routine.service (never wake-alarm.service — that skips the workout lock) when local time is inside [alarm, alarm + TRIGGER_CATCHUP_MINUTES). The catch-up window covers a suspended or just-booted machine; starting an already-active unit is a no-op, so an alarm on screen needs no extra dedupe state. - wake-alarm.service loses its [Install]; install.sh disables it. A timer rather than a resident scheduler: each tick is a fresh process, so there is no daemon to die silently and no wall-clock jump to handle across suspend/resume. Ticks are per-minute for exact-minute firing, but only hit the network when the cache is older than 5 minutes — ~288 GitHub reads/day, and phone changes land within ~5 minutes. Degraded reads log at INFO, not WARNING: at one tick a minute a persistent condition would be 1440 identical journal lines a day. The one WARNING kept is _trigger firing on a non-phone alarm time. Token resolution gains a fallback to the sibling apps' repo-scoped sync_token, in both readers, so one PAT covers every app on kuhyx/syncs. Note this surfaced that wake-alarm-sync/ does not exist in kuhyx/syncs at all: the phone has never completed a push since the crdt_sync migration, so the hibernate path had been silently using the +8h fallback. Fixing that is a phone-side action (reconnect GitHub with repo scope, then one "Set Alarm & Sync"). Alarms edited directly in the Clock app still do not reach the sync — that needs an ACTION_NEXT_ALARM_CLOCK_CHANGED receiver. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
191 lines
9.9 KiB
Markdown
191 lines
9.9 KiB
Markdown
# CLAUDE.md — wake_alarm
|
|
|
|
## What this does
|
|
|
|
A weekend wake alarm: on alarm days (`ALARM_DAYS` in `_constants.py` — Mon,
|
|
Fri, Sat, Sun), 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 same duplication applies
|
|
to the token search order: `SYNC_CONFIG_FILE` / `SYNC_TOKEN_FALLBACK_FILES` in
|
|
`_constants.py` are mirrored by `read_sync_token()` in the wrapper.
|
|
|
|
## `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 Python reader; `shutdown-wrapper.sh` is a second, jq-based
|
|
reader of the **same file**. Keep their parse and token order in step.
|
|
|
|
- Source of truth: `wake-alarm-sync/devices/phone/alarm.json` in the private
|
|
`kuhyx/syncs` repo, written by `phone_app/` as a `crdt_sync` log. The value
|
|
lives at `.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`.
|
|
- Token: `~/.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**.
|
|
- `_sync` is **stdlib-only** (`urllib.request`) on purpose: the service runs
|
|
`/usr/bin/python`, so every dependency is a production install-path hazard.
|
|
- 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 GitHub 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 <new_dep>"`, 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` is stdlib-only for exactly this reason — don't "improve"
|
|
it to `requests`.
|
|
- 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`.
|