mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 17:43:49 +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>
199 lines
6.4 KiB
Python
199 lines
6.4 KiB
Python
"""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()
|