mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +01:00
Fire the alarm at the phone's time via a per-minute timer
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>
This commit is contained in:
parent
64c25e67e9
commit
87d2629e88
101
CLAUDE.md
101
CLAUDE.md
@ -10,27 +10,82 @@ fullscreen Tk window that must be dismissed (with a typed challenge — see
|
||||
through whatever sink comes up after the monitor wakes, and optionally
|
||||
toggles a TP-Link Tapo P110 smart plug via `python-kasa`.
|
||||
|
||||
## Scheduling — hibernate-based, not a systemd timer
|
||||
## Scheduling — a per-minute timer, with hibernate as the fallback
|
||||
|
||||
There is **no systemd timer** for this package. The wake mechanism is:
|
||||
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.
|
||||
|
||||
1. `shutdown-wrapper.sh`, installed to `/usr/local/bin/shutdown` (shadowing
|
||||
`/usr/bin/shutdown` via PATH order), intercepts shutdown/poweroff calls on
|
||||
alarm nights and calls `rtcwake -m disk` instead — hibernating with the
|
||||
RTC alarm set to wake the machine at `WAKE_AFTER_HOURS` (`_constants.py`)
|
||||
from now.
|
||||
2. `sleep-hook.sh`, installed to `/usr/lib/systemd/system-sleep/`, fires on
|
||||
resume (`$1 == post`) and starts `morning-routine.service` for every
|
||||
logged-in session. That orchestrator (lives in testsAndMisc, not here —
|
||||
see below) runs the alarm first, then the workout screen lock, so the two
|
||||
never fight for the fullscreen.
|
||||
3. `wake-alarm.service` itself is `Type=simple`, started either directly or
|
||||
by the orchestrator, and exits once the alarm is dismissed.
|
||||
**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`).
|
||||
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
|
||||
|
||||
@ -110,12 +165,26 @@ considering a dependency change done.
|
||||
- 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.
|
||||
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`.
|
||||
|
||||
48
MIGRATION.md
48
MIGRATION.md
@ -37,3 +37,51 @@ sync.
|
||||
`LogStore` is always the source of truth.
|
||||
- Any parse miss still falls back to `WAKE_AFTER_HOURS`, so a bad read can
|
||||
never brick the morning wake.
|
||||
|
||||
# Migration: hibernate-scheduled → per-minute trigger timer
|
||||
|
||||
The PC is no longer shut down at night, which broke the whole scheduling
|
||||
model: the wake time used to be decided once, at hibernate, and baked into the
|
||||
RTC. With no shutdown there is no resume, so nothing fired the alarm at all —
|
||||
and `wake-alarm.service` was `WantedBy=graphical-session.target`, so on an
|
||||
always-on machine it fired at *login* time instead (`_should_run_alarm()` has
|
||||
no time gate).
|
||||
|
||||
## What changed
|
||||
|
||||
- **New `wake-alarm-trigger.timer`** ticks every minute and runs
|
||||
`wake_alarm._trigger`, which fires `morning-routine.service` when the
|
||||
current local time is inside
|
||||
`[phone alarm, phone alarm + TRIGGER_CATCHUP_MINUTES)`. This is now the
|
||||
primary path; the phone can move the alarm at any hour and the PC follows.
|
||||
- **New `wake_alarm/_sync.py`** — a Python reader for the same alarm file
|
||||
`shutdown-wrapper.sh` parses with `jq`, plus an on-disk cache
|
||||
(`~/.config/wake_alarm/alarm_cache.json`).
|
||||
- **`wake-alarm.service` lost its `[Install]` section** and is no longer
|
||||
enabled; `install.sh` disables it to clear stale symlinks.
|
||||
- **Token resolution gained a fallback** to the sibling apps'
|
||||
`~/.config/{diet_guard,screen_locker}/sync_token`, in both readers.
|
||||
- `shutdown-wrapper.sh` and `sleep-hook.sh` keep working unchanged, now as the
|
||||
PC-is-powered-down fallback rather than the main mechanism.
|
||||
|
||||
## Surprises / non-obvious decisions
|
||||
|
||||
- **A timer, not a resident scheduler.** Each tick is a fresh process, so
|
||||
there is no daemon to die silently and no wall-clock jump to reason about
|
||||
across suspend/resume. `Persistent=true` plus the catch-up window is what
|
||||
makes a suspended PC still fire on resume.
|
||||
- **Per-minute ticks, but a 5-minute cache.** Ticking every 5 minutes would
|
||||
also quantise *firing* to 5 minutes (07:03 → 07:05). Ticking every minute
|
||||
and only hitting the network when the cache is stale gives exact-minute
|
||||
firing at ~288 GitHub reads/day.
|
||||
- **Degraded reads log at INFO.** At one tick a minute, a WARNING on a
|
||||
persistent condition is 1440 journal lines a day. The single WARNING that
|
||||
survives is `_trigger`'s "firing now on a degraded source".
|
||||
- **The sync had been dead since the crdt_sync migration.** `wake-alarm-sync/`
|
||||
did not exist in `kuhyx/syncs` at all — the phone had never successfully
|
||||
pushed, and `~/.config/wake_alarm/sync.json` was never created, so the
|
||||
hibernate path had been silently using the `+8h` fallback. The phone app
|
||||
needs one successful "Set Alarm & Sync" to create that path.
|
||||
- **Still open:** alarms edited directly in the Android Clock app do not reach
|
||||
the sync. Closing that needs an `ACTION_NEXT_ALARM_CLOCK_CHANGED` receiver
|
||||
in the phone app — planned, not yet built.
|
||||
|
||||
35
README.md
35
README.md
@ -1,9 +1,14 @@
|
||||
# wake_alarm
|
||||
|
||||
A hibernate-scheduled weekend wake alarm: the machine hibernates overnight
|
||||
on alarm days and wakes itself via `rtcwake`, then shows a fullscreen,
|
||||
challenge-dismissed alarm with fan ramp and optional TP-Link Tapo P110
|
||||
smart-plug control.
|
||||
A phone-driven wake alarm for an always-on PC. The alarm time is whatever the
|
||||
companion phone app last synced (via the shared `crdt_sync` transport over a
|
||||
private GitHub repo); a per-minute systemd user timer fires at exactly that
|
||||
time, showing a fullscreen, challenge-dismissed alarm with fan ramp and
|
||||
optional TP-Link Tapo P110 smart-plug control.
|
||||
|
||||
Change the alarm on the phone at any hour and the PC follows within ~5
|
||||
minutes. If the PC does happen to be powered down, a `shutdown` wrapper
|
||||
hibernates with `rtcwake` set to the same synced time, as a fallback.
|
||||
|
||||
## Install
|
||||
|
||||
@ -12,20 +17,28 @@ bash install.sh
|
||||
```
|
||||
|
||||
This installs the package + dependencies into system Python's user
|
||||
site-packages (the systemd service runs `/usr/bin/python` directly, not a
|
||||
venv — see `CLAUDE.md`), installs the systemd user service, the
|
||||
systemd-sleep resume hook, the `shutdown` wrapper that triggers hibernate on
|
||||
alarm nights, the fan-ramp script, and (optionally) `python-kasa` for
|
||||
smart-plug control.
|
||||
site-packages (the systemd units run `/usr/bin/python` directly, not a venv —
|
||||
see `CLAUDE.md`), installs and enables the per-minute trigger timer, the
|
||||
systemd-sleep resume hook, the `shutdown` wrapper that hibernates with
|
||||
`rtcwake` as a fallback, the fan-ramp script, and (optionally) `python-kasa`
|
||||
for smart-plug control.
|
||||
|
||||
Reading the phone's alarm needs a GitHub token with `repo` scope, at
|
||||
`~/.config/wake_alarm/sync.json` as `{"token": "..."}`. If that file is
|
||||
absent, a sibling app's `~/.config/{diet_guard,screen_locker}/sync_token` is
|
||||
used instead, so one token covers every app syncing through `kuhyx/syncs`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
python -m wake_alarm._alarm --demo # test the alarm window (safe, closeable)
|
||||
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
|
||||
```
|
||||
|
||||
The alarm fires automatically via the hibernate/wake cycle once installed;
|
||||
no manual invocation is needed in normal operation.
|
||||
The alarm fires automatically once installed; no manual invocation is needed
|
||||
in normal operation.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
35
install.sh
35
install.sh
@ -6,7 +6,8 @@
|
||||
# What it does:
|
||||
# 1. Installs wake_alarm + dependencies for /usr/bin/python
|
||||
# 2. Installs system dependencies (alsa-utils, ddcutil)
|
||||
# 3. Copies wake-alarm.service to ~/.config/systemd/user/ and enables it
|
||||
# 3. Installs the systemd user units and enables the per-minute trigger timer
|
||||
# (which fires the alarm at the time the phone synced)
|
||||
# 4. Installs the systemd-sleep hook (restarts alarm after hibernate resume)
|
||||
# 5. Adds a sudoers entry for passwordless rtcwake
|
||||
# 6. Installs shutdown wrapper so "shutdown now" also hibernates on alarm nights
|
||||
@ -21,6 +22,8 @@ SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
readonly SCRIPT_DIR
|
||||
readonly REPO_DIR="$SCRIPT_DIR"
|
||||
SERVICE_FILE="$SCRIPT_DIR/wake-alarm.service"
|
||||
TRIGGER_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-trigger.service"
|
||||
TRIGGER_TIMER_SRC="$SCRIPT_DIR/wake-alarm-trigger.timer"
|
||||
SLEEP_HOOK_SRC="$SCRIPT_DIR/sleep-hook.sh"
|
||||
SHUTDOWN_WRAPPER_SRC="$SCRIPT_DIR/shutdown-wrapper.sh"
|
||||
FANS_SCRIPT_SRC="$SCRIPT_DIR/wake-alarm-fans.sh"
|
||||
@ -49,14 +52,26 @@ else
|
||||
echo " alsa-utils already installed"
|
||||
fi
|
||||
|
||||
# 3. Install systemd user service
|
||||
echo "[3/9] Installing systemd user service..."
|
||||
# 3. Install systemd user units (alarm + the trigger timer that fires it)
|
||||
echo "[3/9] Installing systemd user units..."
|
||||
mkdir -p "$SYSTEMD_USER_DIR"
|
||||
cp "$SERVICE_FILE" "$SYSTEMD_USER_DIR/wake-alarm.service"
|
||||
cp "$TRIGGER_SERVICE_SRC" "$SYSTEMD_USER_DIR/wake-alarm-trigger.service"
|
||||
cp "$TRIGGER_TIMER_SRC" "$SYSTEMD_USER_DIR/wake-alarm-trigger.timer"
|
||||
systemctl --user daemon-reload
|
||||
echo " Installed to $SYSTEMD_USER_DIR/wake-alarm.service"
|
||||
systemctl --user enable wake-alarm.service
|
||||
echo " Service enabled (will start on next boot)"
|
||||
echo " Installed wake-alarm.service, wake-alarm-trigger.{service,timer}"
|
||||
|
||||
# wake-alarm.service must NOT auto-start: the PC is always on, so
|
||||
# graphical-session.target is reached on every login and the alarm has no time
|
||||
# gate — it would fire at login time. The timer owns firing (via
|
||||
# morning-routine.service). Both guards matter under `set -euo pipefail`:
|
||||
# a unit with no [Install] can't be enabled, and disabling a never-enabled unit
|
||||
# can also return non-zero.
|
||||
systemctl --user disable wake-alarm.service 2>/dev/null || true
|
||||
echo " wake-alarm.service left disabled (started only by the trigger)"
|
||||
|
||||
systemctl --user enable --now wake-alarm-trigger.timer
|
||||
echo " Trigger timer enabled and started (ticks once a minute)"
|
||||
|
||||
# 4. Install systemd-sleep hook (restarts alarm after hibernate resume)
|
||||
echo "[4/9] Installing systemd-sleep hook..."
|
||||
@ -134,7 +149,11 @@ else
|
||||
fi
|
||||
|
||||
echo "=== Installation complete ==="
|
||||
echo "The wake alarm will activate on boot for alarm days (Mon, Fri, Sat, Sun)."
|
||||
echo "After hibernate resume the sleep hook will restart the alarm service."
|
||||
echo "The trigger timer ticks once a minute and fires the alarm at the time"
|
||||
echo "the phone last synced (needs ~/.config/wake_alarm/sync.json with a"
|
||||
echo "repo-scoped GitHub token — see MIGRATION.md)."
|
||||
echo "Check what it resolves right now: python -m wake_alarm._sync --show"
|
||||
echo "If the PC is powered down instead, the shutdown wrapper hibernates with"
|
||||
echo "rtcwake set to the same alarm, and the sleep hook runs it on resume."
|
||||
echo "Fans will ramp to 100% while the alarm is active, then restore automatically."
|
||||
echo "To test now: python -m wake_alarm._alarm --demo"
|
||||
|
||||
@ -21,6 +21,12 @@ ignore = [
|
||||
"COM812",
|
||||
"ISC001",
|
||||
"S603",
|
||||
# urllib "audit URL open for permitted schemes". _sync.py builds its one
|
||||
# URL from module constants (never from remote input) and asserts
|
||||
# request.type == "https" before sending the token. Ignored repo-wide
|
||||
# rather than per-line because inline `# noqa` is blocked by a
|
||||
# pre-commit hook; same rationale as S603 above.
|
||||
"S310",
|
||||
]
|
||||
fixable = ["ALL"]
|
||||
unfixable = []
|
||||
|
||||
@ -7,11 +7,17 @@
|
||||
# Install to /usr/local/bin/shutdown (takes priority over /usr/bin/shutdown
|
||||
# because /usr/local/bin appears first in PATH).
|
||||
#
|
||||
# This is the FALLBACK path, for the rare night the PC is actually powered
|
||||
# down. The primary path is wake-alarm-trigger.timer, which ticks once a
|
||||
# minute on the always-on machine and fires at the phone's alarm time without
|
||||
# any rtcwake involvement (see wake_alarm/_trigger.py).
|
||||
#
|
||||
# Wake time is taken from the alarm the phone app pushes to the private
|
||||
# kuhyx/syncs repo via the shared crdt_sync transport (see phone_app/). Falls
|
||||
# back to WAKE_AFTER_HOURS if sync is not configured or unreachable. Config:
|
||||
# ~/.config/wake_alarm/sync.json with key "token" (GitHub PAT/token with
|
||||
# "repo" scope, so the Contents API can read the private repo).
|
||||
# back to WAKE_AFTER_HOURS if sync is not configured or unreachable. Token:
|
||||
# ~/.config/wake_alarm/sync.json key "token", else a sibling app's
|
||||
# ~/.config/{diet_guard,screen_locker}/sync_token. Needs "repo" scope, since
|
||||
# the Contents API must read a private repo.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@ -30,6 +36,37 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
# Resolve the GitHub token, preferring wake_alarm's own JSON config and falling
|
||||
# back to the sibling companion apps' raw single-line token files (one
|
||||
# repo-scoped PAT covers every app syncing through kuhyx/syncs).
|
||||
# Mirrors SYNC_CONFIG_FILE / SYNC_TOKEN_FALLBACK_FILES in
|
||||
# wake_alarm/_constants.py — keep the two readers in step.
|
||||
read_sync_token() {
|
||||
local cfg="$HOME/.config/wake_alarm/sync.json"
|
||||
local token fallback
|
||||
|
||||
if [[ -f "$cfg" ]]; then
|
||||
token=$(jq -r '.token // empty' "$cfg" 2>/dev/null) || token=""
|
||||
if [[ -n "$token" ]]; then
|
||||
printf '%s' "$token"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
for fallback in \
|
||||
"$HOME/.config/diet_guard/sync_token" \
|
||||
"$HOME/.config/screen_locker/sync_token"; do
|
||||
[[ -f "$fallback" ]] || continue
|
||||
token=$(tr -d '[:space:]' < "$fallback") || continue
|
||||
if [[ -n "$token" ]]; then
|
||||
printf '%s' "$token"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Read the alarm time the phone pushed to kuhyx/syncs via crdt_sync.
|
||||
# Returns the Unix epoch (seconds) of the next occurrence of the stored H:M.
|
||||
# Exits non-zero if sync is not configured, unreachable, or malformed.
|
||||
@ -41,12 +78,8 @@ done
|
||||
# [H, "<hlc>"], "alarm_minute": [M, "<hlc>"]}, ...}}. The raw media type makes
|
||||
# the Contents API return that JSON directly, so no base64 decode is needed.
|
||||
get_synced_alarm_epoch() {
|
||||
local cfg="$HOME/.config/wake_alarm/sync.json"
|
||||
[[ -f "$cfg" ]] || return 1
|
||||
|
||||
local token log_json hour minute target_epoch now_epoch
|
||||
token=$(jq -r .token "$cfg" 2>/dev/null) || return 1
|
||||
[[ -n "$token" && "$token" != "null" ]] || return 1
|
||||
token=$(read_sync_token) || return 1
|
||||
|
||||
local url="https://api.github.com/repos/kuhyx/syncs/contents"
|
||||
url="$url/wake-alarm-sync/devices/phone/alarm.json"
|
||||
|
||||
12
wake-alarm-trigger.service
Normal file
12
wake-alarm-trigger.service
Normal file
@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Wake Alarm trigger tick (fire at the phone's alarm time)
|
||||
Documentation=https://github.com/kuhyx/wake-alarm
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# One short check-and-fire tick, run once a minute by wake-alarm-trigger.timer.
|
||||
# It reads the alarm the phone pushed and, on the alarm minute, starts
|
||||
# morning-routine.service. No DISPLAY needed: this process never opens a
|
||||
# window — the unit it starts does.
|
||||
# No PYTHONPATH needed: wake_alarm is pip-installed (see install.sh).
|
||||
ExecStart=/usr/bin/python -m wake_alarm._trigger
|
||||
17
wake-alarm-trigger.timer
Normal file
17
wake-alarm-trigger.timer
Normal file
@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Wake Alarm trigger tick, once a minute
|
||||
Documentation=https://github.com/kuhyx/wake-alarm
|
||||
|
||||
[Timer]
|
||||
# Tick every minute on the minute so the alarm fires on its exact minute.
|
||||
# The tick itself only hits the network when its cache is older than
|
||||
# ALARM_CACHE_MAX_AGE_SECONDS (5 min), so this costs ~288 GitHub reads/day,
|
||||
# not 1440 - see wake_alarm/_sync.py.
|
||||
OnCalendar=*:*:00
|
||||
AccuracySec=1s
|
||||
# Run a missed tick immediately after resume/boot. Combined with the trigger's
|
||||
# catch-up window this is what makes a suspended PC still fire on wake.
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@ -16,5 +16,16 @@ ExecStart=/usr/bin/python -m wake_alarm._alarm --production
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
# There is intentionally NO [Install] section, and nothing auto-starts this
|
||||
# unit. The PC is always on now, so graphical-session.target is reached on
|
||||
# every login — and _should_run_alarm() has no time gate, so a
|
||||
# WantedBy=graphical-session.target here fires the alarm at *login time* on
|
||||
# any alarm day (i.e. every day). That was the bug this design removed.
|
||||
#
|
||||
# What actually runs the alarm: wake-alarm-trigger.timer ticks once a minute
|
||||
# and starts morning-routine.service at the phone's alarm time; that
|
||||
# orchestrator runs `python -m wake_alarm._alarm` as a subprocess (the alarm
|
||||
# first, the workout lock after). This unit survives only as a manual test
|
||||
# handle: `systemctl --user start wake-alarm.service`.
|
||||
#
|
||||
# Do not "fix" the missing auto-start.
|
||||
|
||||
@ -8,13 +8,17 @@ from pathlib import Path
|
||||
# Every day of the week.
|
||||
ALARM_DAYS: frozenset[int] = frozenset({0, 1, 2, 3, 4, 5, 6})
|
||||
|
||||
# How many hours after shutdown the PC should wake
|
||||
# How many hours after shutdown the PC should wake.
|
||||
# Only used by the hibernate fallback path (shutdown-wrapper.sh) for the rare
|
||||
# night the PC is actually powered down; the primary path is the per-minute
|
||||
# trigger timer, which reads the phone's alarm directly (see _trigger.py).
|
||||
WAKE_AFTER_HOURS: int = 8
|
||||
|
||||
# Minutes after alarm starts within which you must dismiss to earn skip
|
||||
DISMISS_WINDOW_MINUTES: int = 30
|
||||
|
||||
# Hour at which the second (fallback) alarm fires if the first was missed
|
||||
# Hour at which the alarm fires when the phone's alarm cannot be resolved at
|
||||
# all — no network *and* no cached read ever written. Minute is implicitly 0.
|
||||
FALLBACK_ALARM_HOUR: int = 13
|
||||
|
||||
# Alarm escalation phase boundaries (minutes from alarm start)
|
||||
@ -58,6 +62,62 @@ WORKOUT_LOG_FILE: Path = (
|
||||
Path.home() / "screen-locker" / "screen_locker" / "workout_log.json"
|
||||
)
|
||||
|
||||
# --- Phone alarm sync (crdt_sync over GitHub) -----------------------------
|
||||
# The phone app pushes its alarm to a private repo as a crdt_sync log; the PC
|
||||
# reads it back. shutdown-wrapper.sh reads the SAME file with jq for the
|
||||
# hibernate fallback — keep the two readers in step if any of this changes.
|
||||
|
||||
# GitHub token config, key "token" (PAT with "repo" scope: the repo is private).
|
||||
SYNC_CONFIG_FILE: Path = Path.home() / ".config" / "wake_alarm" / "sync.json"
|
||||
|
||||
# Fallback token locations, tried in order when SYNC_CONFIG_FILE is absent.
|
||||
# The sibling companion apps already store a repo-scoped PAT for this same
|
||||
# kuhyx/syncs repo as a raw single-line file, so reusing one means a single
|
||||
# token to rotate instead of three copies drifting out of sync.
|
||||
# shutdown-wrapper.sh reads the same list — keep the two in step.
|
||||
SYNC_TOKEN_FALLBACK_FILES: tuple[Path, ...] = (
|
||||
Path.home() / ".config" / "diet_guard" / "sync_token",
|
||||
Path.home() / ".config" / "screen_locker" / "sync_token",
|
||||
)
|
||||
|
||||
# Where a successful read is cached, so a network blip can't cost a wake-up.
|
||||
ALARM_CACHE_FILE: Path = Path.home() / ".config" / "wake_alarm" / "alarm_cache.json"
|
||||
|
||||
# The shared private repo every companion app syncs through, and the phone's
|
||||
# path within it. The phone is the sole writer under a fixed device id, so one
|
||||
# read of its alarm.json is enough — no device discovery or HLC ordering here.
|
||||
SYNC_API_BASE: str = "https://api.github.com"
|
||||
SYNC_REPO_OWNER: str = "kuhyx"
|
||||
SYNC_REPO_NAME: str = "syncs"
|
||||
SYNC_ALARM_PATH: str = "wake-alarm-sync/devices/phone/alarm.json"
|
||||
|
||||
# Valid ranges for a wall-clock alarm, used to reject a malformed sync payload
|
||||
# or a corrupted cache before it can become a fire time.
|
||||
MIN_HOUR: int = 0
|
||||
MAX_HOUR: int = 23
|
||||
MIN_MINUTE: int = 0
|
||||
MAX_MINUTE: int = 59
|
||||
|
||||
# Timeout for the Contents API read. Short: a slow read must never delay the
|
||||
# per-minute trigger tick, and a stale cache is a perfectly good answer.
|
||||
SYNC_HTTP_TIMEOUT_SECONDS: float = 10.0
|
||||
|
||||
# How long a cached alarm is served without re-fetching. The trigger ticks once
|
||||
# a minute (so firing lands on the exact minute), but only touches the network
|
||||
# when the cache is older than this — bounding phone->PC latency at ~5 minutes
|
||||
# while keeping GitHub reads to ~288/day.
|
||||
ALARM_CACHE_MAX_AGE_SECONDS: float = 300.0
|
||||
|
||||
# How long after the alarm instant the trigger will still fire. Covers a
|
||||
# suspended PC, a missed timer tick, and a machine booted mid-window. Also the
|
||||
# reason a phone alarm moved *earlier* mid-window still fires promptly.
|
||||
TRIGGER_CATCHUP_MINUTES: int = 60
|
||||
|
||||
# The unit the trigger starts. Deliberately the orchestrator, never
|
||||
# wake-alarm.service directly: the orchestrator runs the alarm first and the
|
||||
# workout lock after, so the two never fight for the fullscreen.
|
||||
TRIGGER_TARGET_UNIT: str = "morning-routine.service"
|
||||
|
||||
# TP-Link Tapo P110 smart-plug config file (JSON).
|
||||
# Create with mode 0600 and these keys: host, email, password.
|
||||
# Example contents: a JSON object mapping host -> "192.168.x.x", email ->
|
||||
|
||||
368
wake_alarm/_sync.py
Normal file
368
wake_alarm/_sync.py
Normal file
@ -0,0 +1,368 @@
|
||||
"""Read the alarm time the phone pushed, with an on-disk cache and fallbacks.
|
||||
|
||||
The phone app (``phone_app/``) is the sole writer: it stores the chosen alarm
|
||||
in a ``crdt_sync`` ``LogStore`` and pushes that log to a fixed path in the
|
||||
private ``kuhyx/syncs`` repo. This module is the PC-side reader used by the
|
||||
per-minute trigger (see ``_trigger.py``).
|
||||
|
||||
``shutdown-wrapper.sh`` reads the **same file** with ``jq`` for the hibernate
|
||||
fallback path. The parse here mirrors it exactly (``.alarm.fields.alarm_hour``
|
||||
and ``.alarm.fields.alarm_minute``, first tuple element) so the two readers
|
||||
cannot drift.
|
||||
|
||||
Deliberately stdlib-only (``urllib.request`` + ``json``): the alarm runs under
|
||||
``/usr/bin/python``, not a venv, so every added dependency is a production
|
||||
install-path hazard (see ``CLAUDE.md``). A read is not worth that risk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from wake_alarm._constants import (
|
||||
ALARM_CACHE_FILE,
|
||||
ALARM_CACHE_MAX_AGE_SECONDS,
|
||||
FALLBACK_ALARM_HOUR,
|
||||
MAX_HOUR,
|
||||
MAX_MINUTE,
|
||||
MIN_HOUR,
|
||||
MIN_MINUTE,
|
||||
SYNC_ALARM_PATH,
|
||||
SYNC_API_BASE,
|
||||
SYNC_CONFIG_FILE,
|
||||
SYNC_HTTP_TIMEOUT_SECONDS,
|
||||
SYNC_REPO_NAME,
|
||||
SYNC_REPO_OWNER,
|
||||
SYNC_TOKEN_FALLBACK_FILES,
|
||||
)
|
||||
from wake_alarm._logging_setup import configure_logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Keys inside the crdt_sync log the phone writes.
|
||||
_RECORD_ID = "alarm"
|
||||
_HOUR_FIELD = "alarm_hour"
|
||||
_MINUTE_FIELD = "alarm_minute"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Alarm:
|
||||
"""A wall-clock alarm time, in the phone's (and the PC's) local timezone.
|
||||
|
||||
Attributes:
|
||||
hour: Hour of day, 0-23.
|
||||
minute: Minute of hour, 0-59.
|
||||
source: Where the value came from ("remote", "cache", "stale-cache"
|
||||
or "fallback"). Purely informational — used for logging and the
|
||||
``--show`` output so a wrong alarm is diagnosable at a glance.
|
||||
"""
|
||||
|
||||
hour: int
|
||||
minute: int
|
||||
source: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Render as ``HH:MM (source)``."""
|
||||
return f"{self.hour:02d}:{self.minute:02d} ({self.source})"
|
||||
|
||||
|
||||
def _in_range(hour: int, minute: int) -> bool:
|
||||
"""Check that an (hour, minute) pair is a real wall-clock time.
|
||||
|
||||
Guards both readers: a malformed sync payload and a corrupted cache must
|
||||
never become a fire time.
|
||||
|
||||
Args:
|
||||
hour: Candidate hour.
|
||||
minute: Candidate minute.
|
||||
|
||||
Returns:
|
||||
True if both values are within a 24-hour clock.
|
||||
"""
|
||||
return MIN_HOUR <= hour <= MAX_HOUR and MIN_MINUTE <= minute <= MAX_MINUTE
|
||||
|
||||
|
||||
def _read_token_config() -> str | None:
|
||||
"""Read the token from this package's own JSON config.
|
||||
|
||||
Returns:
|
||||
The token, or None if the file is missing, unreadable, malformed, or
|
||||
holds no usable token.
|
||||
"""
|
||||
try:
|
||||
with SYNC_CONFIG_FILE.open() as handle:
|
||||
config = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
token = config.get("token")
|
||||
if not isinstance(token, str) or not token:
|
||||
return None
|
||||
return token
|
||||
|
||||
|
||||
def _read_token_fallback() -> str | None:
|
||||
"""Read the token from a sibling app's raw single-line token file.
|
||||
|
||||
Returns:
|
||||
The first usable token found, or None if none of the fallback files
|
||||
exists or holds one.
|
||||
"""
|
||||
for path in SYNC_TOKEN_FALLBACK_FILES:
|
||||
try:
|
||||
token = path.read_text().strip()
|
||||
except OSError:
|
||||
continue
|
||||
if token:
|
||||
_logger.debug("Using sync token from %s", path)
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def _read_token() -> str | None:
|
||||
"""Read the GitHub token, preferring this package's own config.
|
||||
|
||||
Falls back to the sibling companion apps' token files so one repo-scoped
|
||||
PAT covers every app syncing through ``kuhyx/syncs``.
|
||||
|
||||
Returns:
|
||||
The token, or None if no configured source holds one.
|
||||
"""
|
||||
# No warning on a miss here: the caller reports "no token" once. Logging
|
||||
# twice for one unconfigured file makes every tick noisy in the journal.
|
||||
return _read_token_config() or _read_token_fallback()
|
||||
|
||||
|
||||
def _extract_field(log: dict[str, Any], field: str) -> int | None:
|
||||
"""Pull one integer field out of the phone's crdt_sync log.
|
||||
|
||||
The stored shape is ``{"alarm": {"fields": {"<field>": [value, "<hlc>"]}}}``
|
||||
— each field is a (value, HLC) pair, so the value is element 0.
|
||||
|
||||
Args:
|
||||
log: The parsed log JSON.
|
||||
field: Field name to extract (e.g. ``alarm_hour``).
|
||||
|
||||
Returns:
|
||||
The integer value, or None if anything about the shape is unexpected.
|
||||
"""
|
||||
record = log.get(_RECORD_ID)
|
||||
if not isinstance(record, dict):
|
||||
return None
|
||||
fields = record.get("fields")
|
||||
if not isinstance(fields, dict):
|
||||
return None
|
||||
pair = fields.get(field)
|
||||
# bool is an int subclass; an alarm hour of True would be nonsense.
|
||||
if not isinstance(pair, list) or not pair:
|
||||
return None
|
||||
value = pair[0]
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _parse_log(raw: str) -> tuple[int, int] | None:
|
||||
"""Parse the phone's log into an (hour, minute) pair.
|
||||
|
||||
Args:
|
||||
raw: The raw JSON text returned by the Contents API.
|
||||
|
||||
Returns:
|
||||
The (hour, minute) pair, or None if the payload is malformed or the
|
||||
values are out of range.
|
||||
"""
|
||||
try:
|
||||
log = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
_logger.warning("Synced alarm is not valid JSON")
|
||||
return None
|
||||
if not isinstance(log, dict):
|
||||
return None
|
||||
hour = _extract_field(log, _HOUR_FIELD)
|
||||
minute = _extract_field(log, _MINUTE_FIELD)
|
||||
if hour is None or minute is None:
|
||||
_logger.warning("Synced alarm is missing hour/minute fields")
|
||||
return None
|
||||
if not _in_range(hour, minute):
|
||||
_logger.warning("Synced alarm out of range: %d:%d", hour, minute)
|
||||
return None
|
||||
return hour, minute
|
||||
|
||||
|
||||
def fetch_remote_alarm() -> tuple[int, int] | None:
|
||||
"""Fetch the alarm the phone last pushed to GitHub.
|
||||
|
||||
Uses the raw media type so the Contents API returns the file body directly
|
||||
rather than base64-wrapped JSON.
|
||||
|
||||
Returns:
|
||||
The (hour, minute) pair, or None if unconfigured, unreachable, or
|
||||
malformed. Never raises — a failed read must degrade to the cache,
|
||||
not crash the trigger.
|
||||
"""
|
||||
token = _read_token()
|
||||
if token is None:
|
||||
# INFO, not WARNING: get_alarm() runs on every one-minute trigger tick,
|
||||
# so a persistent condition logged loudly here is 1440 identical
|
||||
# journal lines a day. The trigger warns once, at the moment it
|
||||
# actually fires on a degraded source — that is the actionable event.
|
||||
_logger.info("No sync token configured at %s", SYNC_CONFIG_FILE)
|
||||
return None
|
||||
|
||||
url = (
|
||||
f"{SYNC_API_BASE}/repos/{SYNC_REPO_OWNER}/{SYNC_REPO_NAME}"
|
||||
f"/contents/{SYNC_ALARM_PATH}"
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/vnd.github.raw",
|
||||
},
|
||||
)
|
||||
# The URL is built from module constants, never from remote input, but
|
||||
# assert the scheme anyway: this call carries a credential, and an http://
|
||||
# or file:// target would leak it. Belt and braces for a bad edit above.
|
||||
if request.type != "https":
|
||||
_logger.error("Refusing to send the sync token over %s", request.type)
|
||||
return None
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request, timeout=SYNC_HTTP_TIMEOUT_SECONDS
|
||||
) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
except (urllib.error.URLError, OSError, UnicodeDecodeError) as exc:
|
||||
# INFO for the same reason as the no-token path: a persistent failure
|
||||
# (404 because the phone never pushed, an expired token, no network)
|
||||
# would otherwise be one WARNING per cache expiry, forever.
|
||||
_logger.info("Cannot fetch synced alarm: %s", exc)
|
||||
return None
|
||||
|
||||
return _parse_log(raw)
|
||||
|
||||
|
||||
def _read_cache() -> tuple[int, int, float] | None:
|
||||
"""Read the cached alarm.
|
||||
|
||||
Returns:
|
||||
An (hour, minute, fetched_at) triple, or None if there is no usable
|
||||
cache.
|
||||
"""
|
||||
try:
|
||||
with ALARM_CACHE_FILE.open() as handle:
|
||||
cached = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(cached, dict):
|
||||
return None
|
||||
hour = cached.get("hour")
|
||||
minute = cached.get("minute")
|
||||
fetched_at = cached.get("fetched_at")
|
||||
if not isinstance(hour, int) or not isinstance(minute, int):
|
||||
return None
|
||||
if not isinstance(fetched_at, (int, float)):
|
||||
return None
|
||||
if not _in_range(hour, minute):
|
||||
return None
|
||||
return hour, minute, float(fetched_at)
|
||||
|
||||
|
||||
def _write_cache(hour: int, minute: int) -> None:
|
||||
"""Persist a freshly fetched alarm so later reads survive a network loss.
|
||||
|
||||
Failures are logged and swallowed: an unwritable cache degrades the next
|
||||
read to a live fetch, which is not worth failing a trigger tick over.
|
||||
|
||||
Args:
|
||||
hour: Hour of day, 0-23.
|
||||
minute: Minute of hour, 0-59.
|
||||
"""
|
||||
entry = {"hour": hour, "minute": minute, "fetched_at": time.time()}
|
||||
try:
|
||||
ALARM_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with ALARM_CACHE_FILE.open("w") as handle:
|
||||
json.dump(entry, handle, indent=2)
|
||||
except OSError as exc:
|
||||
_logger.warning("Cannot write alarm cache: %s", exc)
|
||||
|
||||
|
||||
def get_alarm(*, max_cache_age_seconds: float = ALARM_CACHE_MAX_AGE_SECONDS) -> Alarm:
|
||||
"""Resolve the alarm to fire today, degrading gracefully at every step.
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. A cache entry younger than *max_cache_age_seconds* (no network).
|
||||
2. A live fetch from GitHub (cached on success).
|
||||
3. Any cache entry, however stale — better an old alarm than none.
|
||||
4. ``FALLBACK_ALARM_HOUR:00``, if the alarm has never been read at all.
|
||||
|
||||
Args:
|
||||
max_cache_age_seconds: How long a cached read is served without
|
||||
re-fetching. Injectable so tests and ``--show`` can force a live
|
||||
read.
|
||||
|
||||
Returns:
|
||||
The resolved alarm. Always succeeds.
|
||||
"""
|
||||
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")
|
||||
|
||||
remote = fetch_remote_alarm()
|
||||
if remote is not None:
|
||||
_write_cache(*remote)
|
||||
return Alarm(remote[0], remote[1], "remote")
|
||||
|
||||
# Both degraded paths log at INFO for the same per-minute-tick reason as
|
||||
# _read_token above; _trigger warns when it fires on a degraded source.
|
||||
if cached is not None:
|
||||
_logger.info("Using stale cached alarm — sync read failed")
|
||||
return Alarm(cached[0], cached[1], "stale-cache")
|
||||
|
||||
_logger.info(
|
||||
"No synced or cached alarm — falling back to %02d:00",
|
||||
FALLBACK_ALARM_HOUR,
|
||||
)
|
||||
return Alarm(FALLBACK_ALARM_HOUR, 0, "fallback")
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
"""Parse CLI arguments for the sync reader."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Show the alarm time the phone last synced."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--show",
|
||||
action="store_true",
|
||||
help="Print the resolved alarm and where it came from.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-cache",
|
||||
action="store_true",
|
||||
help="Ignore cache freshness and force a live fetch.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point: print the resolved alarm (``--show`` is the only mode)."""
|
||||
configure_logging()
|
||||
args = _parse_args(sys.argv[1:])
|
||||
max_age = 0.0 if args.no_cache else ALARM_CACHE_MAX_AGE_SECONDS
|
||||
sys.stdout.write(f"{get_alarm(max_cache_age_seconds=max_age)}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
198
wake_alarm/_trigger.py
Normal file
198
wake_alarm/_trigger.py
Normal file
@ -0,0 +1,198 @@
|
||||
"""Per-minute tick that fires the morning routine at the phone's alarm time.
|
||||
|
||||
Run by ``wake-alarm-trigger.timer`` once a minute. Exits in milliseconds on an
|
||||
ordinary tick; on the alarm minute it starts ``morning-routine.service`` and
|
||||
exits.
|
||||
|
||||
Why a timer rather than a resident scheduler: each tick is a fresh, stateless
|
||||
process, so there is no long-lived daemon to die silently and no wall-clock
|
||||
jump to reason about when the machine suspends and resumes.
|
||||
|
||||
Why it starts the *orchestrator* and not ``wake-alarm.service``: the
|
||||
orchestrator runs the alarm first and the workout lock afterwards, so the two
|
||||
never fight for the fullscreen. Bypassing it is the exact failure ``CLAUDE.md``
|
||||
documents. Starting an already-running unit is a no-op, which also means an
|
||||
alarm currently on screen needs no extra dedupe bookkeeping.
|
||||
|
||||
All times are **local**. The phone stores a wall-clock H:M, so the fire instant
|
||||
must be built in the same timezone the user reads off their phone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from wake_alarm._constants import (
|
||||
ALARM_DAYS,
|
||||
TRIGGER_CATCHUP_MINUTES,
|
||||
TRIGGER_TARGET_UNIT,
|
||||
)
|
||||
from wake_alarm._logging_setup import configure_logging
|
||||
from wake_alarm._state import (
|
||||
was_alarm_dismissed_today,
|
||||
was_workout_logged_today,
|
||||
)
|
||||
from wake_alarm._sync import Alarm, get_alarm
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
"""Return the current time as an aware datetime in the system's timezone.
|
||||
|
||||
Aware-and-local, not naive: the phone stores a wall-clock H:M, so the fire
|
||||
instant has to be built in the timezone the user reads off their phone —
|
||||
but carrying the offset explicitly keeps the comparisons unambiguous.
|
||||
|
||||
Returns:
|
||||
The current local time, with tzinfo set. Also the seam tests patch.
|
||||
"""
|
||||
return datetime.now(tz=timezone.utc).astimezone()
|
||||
|
||||
|
||||
def _fire_window(alarm: Alarm, now: datetime) -> tuple[datetime, datetime]:
|
||||
"""Compute today's fire instant and the end of its catch-up window.
|
||||
|
||||
Args:
|
||||
alarm: The resolved alarm time.
|
||||
now: Current local time, used only for its date.
|
||||
|
||||
Returns:
|
||||
A (fire_at, window_end) pair in local time.
|
||||
"""
|
||||
# Drop to a naive wall-clock time before substituting H:M, then let
|
||||
# astimezone() re-derive the offset. Calling replace() straight on an
|
||||
# aware datetime would keep *now*'s UTC offset, which is wrong across a
|
||||
# DST boundary — e.g. deciding a 06:40 alarm from a 03:00 tick on the
|
||||
# night the clocks move.
|
||||
fire_at = (
|
||||
now.replace(tzinfo=None)
|
||||
.replace(hour=alarm.hour, minute=alarm.minute, second=0, microsecond=0)
|
||||
.astimezone()
|
||||
)
|
||||
return fire_at, fire_at + timedelta(minutes=TRIGGER_CATCHUP_MINUTES)
|
||||
|
||||
|
||||
def _skip_reason(now: datetime) -> str | None:
|
||||
"""Return why the alarm must not fire right now, or None if it may.
|
||||
|
||||
Checks only the gates that are independent of the clock: the day gate and
|
||||
the two "already handled today" gates shared with ``_alarm.py``.
|
||||
|
||||
Args:
|
||||
now: Current local time.
|
||||
|
||||
Returns:
|
||||
A human-readable reason, or None when nothing blocks firing.
|
||||
"""
|
||||
if now.weekday() not in ALARM_DAYS:
|
||||
return "not an alarm day"
|
||||
if was_alarm_dismissed_today():
|
||||
return "alarm already dismissed today"
|
||||
if was_workout_logged_today():
|
||||
return "workout already logged today"
|
||||
return None
|
||||
|
||||
|
||||
def _start_target_unit() -> bool:
|
||||
"""Start the morning routine unit.
|
||||
|
||||
Returns:
|
||||
True if systemd accepted the start, False otherwise. A failure is
|
||||
logged and swallowed: the next tick retries, and the catch-up window
|
||||
means a transient failure does not cost the whole wake-up.
|
||||
"""
|
||||
cmd = ["systemctl", "--user", "start", TRIGGER_TARGET_UNIT]
|
||||
try:
|
||||
result = subprocess.run(cmd, check=False, capture_output=True)
|
||||
except OSError:
|
||||
_logger.warning("Failed to run %s", " ".join(cmd), exc_info=True)
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
_logger.warning(
|
||||
"%s failed (rc=%d): %s",
|
||||
" ".join(cmd),
|
||||
result.returncode,
|
||||
result.stderr.decode("utf-8", "replace").strip(),
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _decide(now: datetime, alarm: Alarm) -> tuple[bool, str]:
|
||||
"""Decide whether this tick should fire the alarm.
|
||||
|
||||
Args:
|
||||
now: Current local time.
|
||||
alarm: The resolved alarm time.
|
||||
|
||||
Returns:
|
||||
A (should_fire, reason) pair; *reason* explains the decision either
|
||||
way and is what ``--dry-run`` prints.
|
||||
"""
|
||||
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 "
|
||||
f"{TRIGGER_CATCHUP_MINUTES} minutes ago — window closed"
|
||||
)
|
||||
return True, f"alarm {alarm} is due (since {fire_at:%H:%M})"
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
"""Parse CLI arguments for the trigger."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fire the morning routine at the phone's alarm time."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print the decision without starting anything.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for one timer tick."""
|
||||
configure_logging()
|
||||
args = _parse_args(sys.argv[1:])
|
||||
|
||||
now = _now()
|
||||
alarm = get_alarm()
|
||||
should_fire, reason = _decide(now, alarm)
|
||||
|
||||
if args.dry_run:
|
||||
verdict = "WOULD FIRE" if should_fire else "not firing"
|
||||
|
||||
sys.stdout.write(f"{now:%Y-%m-%d %H:%M} — {verdict}: {reason}\n")
|
||||
return
|
||||
|
||||
if not should_fire:
|
||||
_logger.debug("Not firing: %s", reason)
|
||||
return
|
||||
|
||||
# The one moment a degraded read is worth shouting about: we are firing
|
||||
# now, and the time we are firing at did not come from the phone.
|
||||
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,
|
||||
)
|
||||
|
||||
_logger.warning("Firing %s: %s", TRIGGER_TARGET_UNIT, reason)
|
||||
_start_target_unit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
387
wake_alarm/tests/test_sync.py
Normal file
387
wake_alarm/tests/test_sync.py
Normal file
@ -0,0 +1,387 @@
|
||||
"""Tests for _sync.py — reading the alarm the phone pushed, with fallbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
import urllib.error
|
||||
|
||||
import pytest
|
||||
|
||||
from wake_alarm._sync import (
|
||||
Alarm,
|
||||
_extract_field,
|
||||
_parse_args,
|
||||
_parse_log,
|
||||
_read_cache,
|
||||
_read_token,
|
||||
_read_token_fallback,
|
||||
_write_cache,
|
||||
fetch_remote_alarm,
|
||||
get_alarm,
|
||||
main,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _log(hour: object = 7, minute: object = 30) -> str:
|
||||
"""Build a crdt_sync log payload shaped like the phone's."""
|
||||
return json.dumps(
|
||||
{
|
||||
"alarm": {
|
||||
"fields": {
|
||||
"alarm_hour": [hour, "hlc-1"],
|
||||
"alarm_minute": [minute, "hlc-1"],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_file(tmp_path: Path) -> Path:
|
||||
"""Provide a temporary sync config path."""
|
||||
return tmp_path / "sync.json"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache_file(tmp_path: Path) -> Path:
|
||||
"""Provide a temporary alarm cache path."""
|
||||
return tmp_path / "cache" / "alarm_cache.json"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fallback_files(tmp_path: Path) -> tuple[Path, Path]:
|
||||
"""Provide two temporary sibling-app token paths."""
|
||||
return tmp_path / "diet_guard_token", tmp_path / "screen_locker_token"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_paths(
|
||||
config_file: Path, cache_file: Path, fallback_files: tuple[Path, Path]
|
||||
) -> None:
|
||||
"""Redirect every on-disk path into tmp_path for every test."""
|
||||
with (
|
||||
patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
|
||||
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
|
||||
patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
class TestAlarm:
|
||||
"""Tests for the Alarm value object."""
|
||||
|
||||
def test_str_pads_and_names_source(self) -> None:
|
||||
"""Render zero-padded HH:MM with the source in parentheses."""
|
||||
assert str(Alarm(7, 5, "remote")) == "07:05 (remote)"
|
||||
|
||||
|
||||
class TestReadToken:
|
||||
"""Tests for _read_token."""
|
||||
|
||||
def test_missing_file(self) -> None:
|
||||
"""A missing config file yields no token."""
|
||||
assert _read_token() is None
|
||||
|
||||
def test_malformed_json(self, config_file: Path) -> None:
|
||||
"""Unparsable JSON yields no token."""
|
||||
config_file.write_text("{not json")
|
||||
assert _read_token() is None
|
||||
|
||||
def test_not_a_dict(self, config_file: Path) -> None:
|
||||
"""A JSON array is not a config object."""
|
||||
config_file.write_text("[]")
|
||||
assert _read_token() is None
|
||||
|
||||
@pytest.mark.parametrize("value", [None, "", 42])
|
||||
def test_unusable_token_values(self, config_file: Path, value: object) -> None:
|
||||
"""Absent, empty and non-string tokens are all rejected."""
|
||||
config_file.write_text(json.dumps({"token": value}))
|
||||
assert _read_token() is None
|
||||
|
||||
def test_valid_token(self, config_file: Path) -> None:
|
||||
"""A non-empty string token is returned."""
|
||||
config_file.write_text(json.dumps({"token": "ghp_x"}))
|
||||
assert _read_token() == "ghp_x"
|
||||
|
||||
|
||||
class TestTokenFallback:
|
||||
"""Tests for reusing a sibling companion app's token file."""
|
||||
|
||||
def test_no_fallback_files(self) -> None:
|
||||
"""With nothing on disk anywhere there is no token."""
|
||||
assert _read_token_fallback() is None
|
||||
assert _read_token() is None
|
||||
|
||||
def test_reads_raw_single_line_token(
|
||||
self, fallback_files: tuple[Path, Path]
|
||||
) -> None:
|
||||
"""A sibling token file is a raw PAT, not JSON — and is stripped."""
|
||||
fallback_files[0].write_text(" ghp_sibling\n")
|
||||
assert _read_token_fallback() == "ghp_sibling"
|
||||
|
||||
def test_skips_empty_file(self, fallback_files: tuple[Path, Path]) -> None:
|
||||
"""An empty first file falls through to the second."""
|
||||
fallback_files[0].write_text(" \n")
|
||||
fallback_files[1].write_text("ghp_second")
|
||||
assert _read_token_fallback() == "ghp_second"
|
||||
|
||||
def test_skips_unreadable_file(self, fallback_files: tuple[Path, Path]) -> None:
|
||||
"""An unreadable file is skipped rather than fatal."""
|
||||
fallback_files[1].write_text("ghp_second")
|
||||
with patch(
|
||||
"pathlib.Path.read_text",
|
||||
side_effect=[OSError("denied"), "ghp_second"],
|
||||
):
|
||||
assert _read_token_fallback() == "ghp_second"
|
||||
|
||||
def test_own_config_wins(
|
||||
self, config_file: Path, fallback_files: tuple[Path, Path]
|
||||
) -> None:
|
||||
"""wake_alarm's own config takes precedence over a sibling's."""
|
||||
config_file.write_text(json.dumps({"token": "ghp_own"}))
|
||||
fallback_files[0].write_text("ghp_sibling")
|
||||
assert _read_token() == "ghp_own"
|
||||
|
||||
def test_falls_back_when_config_absent(
|
||||
self, fallback_files: tuple[Path, Path]
|
||||
) -> None:
|
||||
"""With no own config, the sibling token is used."""
|
||||
fallback_files[0].write_text("ghp_sibling")
|
||||
assert _read_token() == "ghp_sibling"
|
||||
|
||||
|
||||
class TestExtractField:
|
||||
"""Tests for _extract_field's tolerance of malformed logs."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"log",
|
||||
[
|
||||
{},
|
||||
{"alarm": "not a dict"},
|
||||
{"alarm": {}},
|
||||
{"alarm": {"fields": "not a dict"}},
|
||||
{"alarm": {"fields": {}}},
|
||||
{"alarm": {"fields": {"alarm_hour": "not a list"}}},
|
||||
{"alarm": {"fields": {"alarm_hour": []}}},
|
||||
{"alarm": {"fields": {"alarm_hour": ["7", "hlc"]}}},
|
||||
{"alarm": {"fields": {"alarm_hour": [True, "hlc"]}}},
|
||||
],
|
||||
)
|
||||
def test_malformed_shapes_yield_none(self, log: dict[str, Any]) -> None:
|
||||
"""Every unexpected shape degrades to None rather than raising."""
|
||||
assert _extract_field(log, "alarm_hour") is None
|
||||
|
||||
def test_extracts_value(self) -> None:
|
||||
"""The value is element 0 of the (value, HLC) pair."""
|
||||
log = {"alarm": {"fields": {"alarm_hour": [6, "hlc"]}}}
|
||||
assert _extract_field(log, "alarm_hour") == 6
|
||||
|
||||
|
||||
class TestParseLog:
|
||||
"""Tests for _parse_log."""
|
||||
|
||||
def test_valid(self) -> None:
|
||||
"""A well-formed log parses to an (hour, minute) pair."""
|
||||
assert _parse_log(_log(7, 30)) == (7, 30)
|
||||
|
||||
def test_invalid_json(self) -> None:
|
||||
"""Unparsable JSON yields None."""
|
||||
assert _parse_log("{nope") is None
|
||||
|
||||
def test_not_a_dict(self) -> None:
|
||||
"""A JSON array is not a log."""
|
||||
assert _parse_log("[]") is None
|
||||
|
||||
def test_missing_fields(self) -> None:
|
||||
"""A log without the alarm fields yields None."""
|
||||
assert _parse_log(json.dumps({"alarm": {"fields": {}}})) is None
|
||||
|
||||
@pytest.mark.parametrize(("hour", "minute"), [(24, 0), (-1, 0), (7, 60), (7, -1)])
|
||||
def test_out_of_range(self, hour: int, minute: int) -> None:
|
||||
"""Values outside 0-23 / 0-59 are rejected."""
|
||||
assert _parse_log(_log(hour, minute)) is None
|
||||
|
||||
|
||||
class TestFetchRemoteAlarm:
|
||||
"""Tests for fetch_remote_alarm."""
|
||||
|
||||
def test_no_token(self) -> None:
|
||||
"""Without a token there is nothing to fetch."""
|
||||
assert fetch_remote_alarm() is None
|
||||
|
||||
def _with_token(self, config_file: Path) -> None:
|
||||
config_file.write_text(json.dumps({"token": "ghp_x"}))
|
||||
|
||||
def test_success(self, config_file: Path) -> None:
|
||||
"""A 200 with a valid body yields the pair."""
|
||||
self._with_token(config_file)
|
||||
response = MagicMock()
|
||||
response.read.return_value = _log(6, 15).encode()
|
||||
response.__enter__.return_value = response
|
||||
with patch("urllib.request.urlopen", return_value=response):
|
||||
assert fetch_remote_alarm() == (6, 15)
|
||||
|
||||
def test_sends_raw_media_type_and_auth(self, config_file: Path) -> None:
|
||||
"""The request carries the token and the raw Accept header."""
|
||||
self._with_token(config_file)
|
||||
response = MagicMock()
|
||||
response.read.return_value = _log().encode()
|
||||
response.__enter__.return_value = response
|
||||
with patch("urllib.request.urlopen", return_value=response) as urlopen:
|
||||
fetch_remote_alarm()
|
||||
request = urlopen.call_args[0][0]
|
||||
assert request.get_header("Authorization") == "token ghp_x"
|
||||
assert request.get_header("Accept") == "application/vnd.github.raw"
|
||||
assert "kuhyx/syncs" in request.full_url
|
||||
|
||||
def test_refuses_a_non_https_scheme(self, config_file: Path) -> None:
|
||||
"""The token is never sent over a plaintext or file:// URL."""
|
||||
self._with_token(config_file)
|
||||
with (
|
||||
patch("wake_alarm._sync.SYNC_API_BASE", "http://api.github.com"),
|
||||
patch("urllib.request.urlopen") as urlopen,
|
||||
):
|
||||
assert fetch_remote_alarm() is None
|
||||
urlopen.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
urllib.error.URLError("down"),
|
||||
OSError("timeout"),
|
||||
UnicodeDecodeError("utf-8", b"\xff", 0, 1, "bad"),
|
||||
],
|
||||
)
|
||||
def test_network_failures_yield_none(
|
||||
self, config_file: Path, exc: Exception
|
||||
) -> None:
|
||||
"""Every transport failure degrades to None, never raises."""
|
||||
self._with_token(config_file)
|
||||
with patch("urllib.request.urlopen", side_effect=exc):
|
||||
assert fetch_remote_alarm() is None
|
||||
|
||||
|
||||
class TestCache:
|
||||
"""Tests for the on-disk cache helpers."""
|
||||
|
||||
def test_roundtrip(self, cache_file: Path) -> None:
|
||||
"""A written cache reads back with the same values."""
|
||||
_write_cache(6, 45)
|
||||
cached = _read_cache()
|
||||
assert cached is not None
|
||||
assert cached[0] == 6
|
||||
assert cached[1] == 45
|
||||
|
||||
def test_write_creates_parent_dir(self, cache_file: Path) -> None:
|
||||
"""The cache directory is created on demand."""
|
||||
_write_cache(6, 45)
|
||||
assert cache_file.exists()
|
||||
|
||||
def test_write_failure_is_swallowed(self) -> None:
|
||||
"""An unwritable cache logs but does not raise."""
|
||||
with patch("pathlib.Path.mkdir", side_effect=OSError("read-only")):
|
||||
_write_cache(6, 45)
|
||||
|
||||
def test_missing_file(self) -> None:
|
||||
"""No cache file yields None."""
|
||||
assert _read_cache() is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
"{not json",
|
||||
"[]",
|
||||
json.dumps({"hour": "6", "minute": 45, "fetched_at": 1.0}),
|
||||
json.dumps({"hour": 6, "minute": "45", "fetched_at": 1.0}),
|
||||
json.dumps({"hour": 6, "minute": 45, "fetched_at": "soon"}),
|
||||
json.dumps({"hour": 24, "minute": 45, "fetched_at": 1.0}),
|
||||
json.dumps({"hour": 6, "minute": 60, "fetched_at": 1.0}),
|
||||
],
|
||||
)
|
||||
def test_malformed_cache_yields_none(self, cache_file: Path, content: str) -> None:
|
||||
"""Any unexpected cache shape is treated as no cache."""
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(content)
|
||||
assert _read_cache() is None
|
||||
|
||||
|
||||
class TestGetAlarm:
|
||||
"""Tests for the get_alarm resolution chain."""
|
||||
|
||||
def test_fresh_cache_skips_network(self, cache_file: Path) -> None:
|
||||
"""A young cache entry is served without any fetch."""
|
||||
_write_cache(6, 45)
|
||||
with patch("wake_alarm._sync.fetch_remote_alarm") as fetch:
|
||||
alarm = get_alarm()
|
||||
fetch.assert_not_called()
|
||||
assert (alarm.hour, alarm.minute, alarm.source) == (6, 45, "cache")
|
||||
|
||||
def test_stale_cache_triggers_fetch(self, cache_file: Path) -> None:
|
||||
"""An old cache entry is refreshed from the network."""
|
||||
_write_cache(6, 45)
|
||||
with patch("wake_alarm._sync.fetch_remote_alarm", return_value=(7, 0)):
|
||||
alarm = get_alarm(max_cache_age_seconds=0)
|
||||
assert (alarm.hour, alarm.minute, alarm.source) == (7, 0, "remote")
|
||||
|
||||
def test_fetch_result_is_cached(self, cache_file: Path) -> None:
|
||||
"""A successful fetch is written to the cache for next time."""
|
||||
with patch("wake_alarm._sync.fetch_remote_alarm", return_value=(7, 0)):
|
||||
get_alarm()
|
||||
assert json.loads(cache_file.read_text())["hour"] == 7
|
||||
|
||||
def test_falls_back_to_stale_cache(self, cache_file: Path) -> None:
|
||||
"""When the fetch fails, an old cache entry is still used."""
|
||||
_write_cache(6, 45)
|
||||
with patch("wake_alarm._sync.fetch_remote_alarm", return_value=None):
|
||||
alarm = get_alarm(max_cache_age_seconds=0)
|
||||
assert (alarm.hour, alarm.minute, alarm.source) == (
|
||||
6,
|
||||
45,
|
||||
"stale-cache",
|
||||
)
|
||||
|
||||
def test_falls_back_to_constant(self) -> None:
|
||||
"""With no cache and no network, the fixed fallback hour is used."""
|
||||
with patch("wake_alarm._sync.fetch_remote_alarm", return_value=None):
|
||||
alarm = get_alarm()
|
||||
assert (alarm.minute, alarm.source) == (0, "fallback")
|
||||
assert alarm.hour == 13
|
||||
|
||||
|
||||
class TestCli:
|
||||
"""Tests for the --show entry point."""
|
||||
|
||||
def test_parse_args_defaults(self) -> None:
|
||||
"""Both flags default to off."""
|
||||
args = _parse_args([])
|
||||
assert not args.show
|
||||
assert not args.no_cache
|
||||
|
||||
def test_main_prints_alarm(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""main prints the resolved alarm."""
|
||||
with (
|
||||
patch("sys.argv", ["_sync", "--show"]),
|
||||
patch(
|
||||
"wake_alarm._sync.get_alarm",
|
||||
return_value=Alarm(7, 30, "remote"),
|
||||
),
|
||||
):
|
||||
main()
|
||||
assert "07:30 (remote)" in capsys.readouterr().out
|
||||
|
||||
def test_no_cache_forces_live_read(self) -> None:
|
||||
"""--no-cache asks get_alarm for a zero-age cache."""
|
||||
with (
|
||||
patch("sys.argv", ["_sync", "--no-cache"]),
|
||||
patch(
|
||||
"wake_alarm._sync.get_alarm",
|
||||
return_value=Alarm(7, 30, "remote"),
|
||||
) as get,
|
||||
):
|
||||
main()
|
||||
assert get.call_args.kwargs["max_cache_age_seconds"] == 0.0
|
||||
252
wake_alarm/tests/test_trigger.py
Normal file
252
wake_alarm/tests/test_trigger.py
Normal file
@ -0,0 +1,252 @@
|
||||
"""Tests for _trigger.py — the per-minute check-and-fire tick."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from wake_alarm._sync import Alarm
|
||||
from wake_alarm._trigger import (
|
||||
_decide,
|
||||
_fire_window,
|
||||
_now,
|
||||
_parse_args,
|
||||
_skip_reason,
|
||||
_start_target_unit,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
def _at(hour: int, minute: int) -> datetime:
|
||||
"""Return a Sunday at the given local wall-clock time, timezone-aware.
|
||||
|
||||
Built the same way production does — naive wall clock, then astimezone()
|
||||
to attach the system offset — so the tests exercise the real comparison
|
||||
semantics rather than a hand-picked fixed offset.
|
||||
"""
|
||||
return datetime(2026, 7, 19, hour, minute, tzinfo=None).astimezone()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ungated() -> None:
|
||||
"""Open both "already handled today" gates unless a test closes them."""
|
||||
with (
|
||||
patch("wake_alarm._trigger.was_alarm_dismissed_today", return_value=False),
|
||||
patch("wake_alarm._trigger.was_workout_logged_today", return_value=False),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
class TestNow:
|
||||
"""Tests for the _now seam."""
|
||||
|
||||
def test_returns_aware_local_time(self) -> None:
|
||||
"""The clock is timezone-aware and in local time, not UTC."""
|
||||
now = _now()
|
||||
assert now.tzinfo is not None
|
||||
# Local, not UTC: the wall-clock hour must match the system clock's.
|
||||
assert now.hour == datetime.now(tz=timezone.utc).astimezone().hour
|
||||
|
||||
|
||||
class TestFireWindow:
|
||||
"""Tests for _fire_window."""
|
||||
|
||||
def test_window_starts_at_the_alarm_minute(self) -> None:
|
||||
"""The fire instant is today at the alarm's H:M, seconds zeroed."""
|
||||
fire_at, _ = _fire_window(Alarm(6, 45, "remote"), _at(3, 12))
|
||||
assert fire_at == _at(6, 45)
|
||||
assert fire_at.second == 0
|
||||
|
||||
def test_window_is_the_catchup_length(self) -> None:
|
||||
"""The window ends TRIGGER_CATCHUP_MINUTES after the alarm."""
|
||||
fire_at, end = _fire_window(Alarm(6, 45, "remote"), _at(3, 12))
|
||||
assert (end - fire_at).total_seconds() == 60 * 60
|
||||
|
||||
def test_fire_instant_is_aware(self) -> None:
|
||||
"""The fire instant keeps a timezone, so comparisons are unambiguous."""
|
||||
fire_at, end = _fire_window(Alarm(6, 45, "remote"), _at(3, 12))
|
||||
assert fire_at.tzinfo is not None
|
||||
assert end.tzinfo is not None
|
||||
|
||||
def test_offset_is_derived_from_the_target_wall_time(self) -> None:
|
||||
"""The offset is re-derived, not inherited from *now*.
|
||||
|
||||
Guards the DST case: deciding a morning alarm from a tick taken on the
|
||||
other side of a clock change must not carry *now*'s stale UTC offset.
|
||||
"""
|
||||
fire_at, _ = _fire_window(Alarm(6, 45, "remote"), _at(3, 12))
|
||||
expected = datetime(2026, 7, 19, 6, 45, tzinfo=None).astimezone()
|
||||
assert fire_at.utcoffset() == expected.utcoffset()
|
||||
assert fire_at == expected
|
||||
|
||||
|
||||
class TestSkipReason:
|
||||
"""Tests for the clock-independent gates."""
|
||||
|
||||
def test_open_when_nothing_blocks(self) -> None:
|
||||
"""With every gate open there is no reason to skip."""
|
||||
assert _skip_reason(_at(7, 0)) is None
|
||||
|
||||
def test_not_an_alarm_day(self) -> None:
|
||||
"""A weekday outside ALARM_DAYS blocks firing."""
|
||||
with patch("wake_alarm._trigger.ALARM_DAYS", frozenset()):
|
||||
assert _skip_reason(_at(7, 0)) == "not an alarm day"
|
||||
|
||||
def test_already_dismissed(self) -> None:
|
||||
"""A dismissal recorded today blocks a re-fire."""
|
||||
with patch(
|
||||
"wake_alarm._trigger.was_alarm_dismissed_today",
|
||||
return_value=True,
|
||||
):
|
||||
assert _skip_reason(_at(7, 0)) == "alarm already dismissed today"
|
||||
|
||||
def test_workout_already_logged(self) -> None:
|
||||
"""A logged workout means the alarm is no longer needed."""
|
||||
with patch("wake_alarm._trigger.was_workout_logged_today", return_value=True):
|
||||
assert _skip_reason(_at(7, 0)) == "workout already logged today"
|
||||
|
||||
|
||||
class TestDecide:
|
||||
"""Tests for the fire/don't-fire decision."""
|
||||
|
||||
def test_before_the_alarm(self) -> None:
|
||||
"""A tick before the alarm minute does not fire."""
|
||||
fire, reason = _decide(_at(6, 44), Alarm(6, 45, "remote"))
|
||||
assert not fire
|
||||
assert "still ahead" in reason
|
||||
|
||||
def test_exactly_on_the_alarm_minute(self) -> None:
|
||||
"""The alarm minute itself fires."""
|
||||
fire, _ = _decide(_at(6, 45), Alarm(6, 45, "remote"))
|
||||
assert fire
|
||||
|
||||
def test_within_the_catchup_window(self) -> None:
|
||||
"""A tick inside the catch-up window still fires (e.g. after resume)."""
|
||||
fire, _ = _decide(_at(7, 30), Alarm(6, 45, "remote"))
|
||||
assert fire
|
||||
|
||||
def test_at_the_window_edge(self) -> None:
|
||||
"""The window is half-open: its end instant no longer fires."""
|
||||
fire, reason = _decide(_at(7, 45), Alarm(6, 45, "remote"))
|
||||
assert not fire
|
||||
assert "window closed" in reason
|
||||
|
||||
def test_long_after_the_alarm(self) -> None:
|
||||
"""Hours later, the window is closed."""
|
||||
fire, reason = _decide(_at(20, 0), Alarm(6, 45, "remote"))
|
||||
assert not fire
|
||||
assert "window closed" in reason
|
||||
|
||||
def test_gate_beats_the_clock(self) -> None:
|
||||
"""A closed gate wins even at the exact alarm minute."""
|
||||
with patch(
|
||||
"wake_alarm._trigger.was_alarm_dismissed_today",
|
||||
return_value=True,
|
||||
):
|
||||
fire, reason = _decide(_at(6, 45), Alarm(6, 45, "remote"))
|
||||
assert not fire
|
||||
assert reason == "alarm already dismissed today"
|
||||
|
||||
def test_alarm_moved_earlier_fires_immediately(self) -> None:
|
||||
"""A phone alarm moved back before now fires on the very next tick."""
|
||||
fire, _ = _decide(_at(6, 50), Alarm(6, 40, "remote"))
|
||||
assert fire
|
||||
|
||||
|
||||
class TestStartTargetUnit:
|
||||
"""Tests for the systemctl invocation."""
|
||||
|
||||
def test_starts_the_orchestrator_not_the_alarm(self) -> None:
|
||||
"""The trigger must start morning-routine, never wake-alarm."""
|
||||
completed = MagicMock(returncode=0)
|
||||
with patch("subprocess.run", return_value=completed) as run:
|
||||
assert _start_target_unit()
|
||||
assert run.call_args[0][0] == [
|
||||
"systemctl",
|
||||
"--user",
|
||||
"start",
|
||||
"morning-routine.service",
|
||||
]
|
||||
|
||||
def test_nonzero_exit_is_reported(self) -> None:
|
||||
"""A systemctl failure returns False rather than raising."""
|
||||
completed = MagicMock(returncode=1, stderr=b"no such unit")
|
||||
with patch("subprocess.run", return_value=completed):
|
||||
assert not _start_target_unit()
|
||||
|
||||
def test_missing_systemctl_is_swallowed(self) -> None:
|
||||
"""An OSError (no systemctl) returns False rather than raising."""
|
||||
with patch("subprocess.run", side_effect=OSError("not found")):
|
||||
assert not _start_target_unit()
|
||||
|
||||
def test_does_not_raise_on_failure(self) -> None:
|
||||
"""check=False: a failing start never raises CalledProcessError."""
|
||||
completed = MagicMock(returncode=1, stderr=b"")
|
||||
with patch("subprocess.run", return_value=completed) as run:
|
||||
_start_target_unit()
|
||||
assert run.call_args.kwargs["check"] is False
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for the tick entry point."""
|
||||
|
||||
def test_parse_args_default(self) -> None:
|
||||
"""--dry-run defaults to off."""
|
||||
assert not _parse_args([]).dry_run
|
||||
|
||||
def _run_main(self, argv: list[str], now: datetime, alarm: Alarm) -> MagicMock:
|
||||
with (
|
||||
patch("sys.argv", ["_trigger", *argv]),
|
||||
patch("wake_alarm._trigger._now", return_value=now),
|
||||
patch("wake_alarm._trigger.get_alarm", return_value=alarm),
|
||||
patch("wake_alarm._trigger._start_target_unit") as start,
|
||||
):
|
||||
main()
|
||||
return start
|
||||
|
||||
def test_fires_at_the_alarm_minute(self) -> None:
|
||||
"""On the alarm minute the target unit is started."""
|
||||
start = self._run_main([], _at(6, 45), Alarm(6, 45, "remote"))
|
||||
start.assert_called_once()
|
||||
|
||||
def test_does_not_fire_early(self) -> None:
|
||||
"""Before the alarm nothing is started."""
|
||||
start = self._run_main([], _at(5, 0), Alarm(6, 45, "remote"))
|
||||
start.assert_not_called()
|
||||
|
||||
def test_dry_run_never_starts_anything(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""--dry-run reports WOULD FIRE without touching systemd."""
|
||||
start = self._run_main(["--dry-run"], _at(6, 45), Alarm(6, 45, "remote"))
|
||||
start.assert_not_called()
|
||||
assert "WOULD FIRE" in capsys.readouterr().out
|
||||
|
||||
def test_dry_run_reports_not_firing(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""--dry-run explains why it is not firing."""
|
||||
self._run_main(["--dry-run"], _at(5, 0), Alarm(6, 45, "remote"))
|
||||
out = capsys.readouterr().out
|
||||
assert "not firing" in out
|
||||
assert "still ahead" in out
|
||||
|
||||
@pytest.mark.parametrize("source", ["fallback", "stale-cache"])
|
||||
def test_warns_when_firing_on_a_degraded_source(
|
||||
self, caplog: pytest.LogCaptureFixture, source: str
|
||||
) -> None:
|
||||
"""Firing on a non-phone alarm time is logged loudly."""
|
||||
with caplog.at_level("WARNING"):
|
||||
self._run_main([], _at(6, 45), Alarm(6, 45, source))
|
||||
assert "degraded alarm source" in caplog.text
|
||||
|
||||
@pytest.mark.parametrize("source", ["remote", "cache"])
|
||||
def test_quiet_when_firing_on_the_phone_alarm(
|
||||
self, caplog: pytest.LogCaptureFixture, source: str
|
||||
) -> None:
|
||||
"""A normal fire does not emit the degraded-source warning."""
|
||||
with caplog.at_level("WARNING"):
|
||||
self._run_main([], _at(6, 45), Alarm(6, 45, source))
|
||||
assert "degraded alarm source" not in caplog.text
|
||||
Loading…
Reference in New Issue
Block a user