wake-alarm/wake_alarm/_state.py
Krzysztof kuhy Rudnicki e67cef0c54 Retire the PC's waking role; keep the trigger
The PC no longer wakes anyone -- the phone does. This deletes the alarm
window, the audio escalation, the sink/reactivation machinery, the power-key
inhibitor, the escape hatch, the curtains, the smart plug, the brightness and
DDC/CI control, the hibernate path (shutdown wrapper + rtcwake + sleep hook)
and the challenge set: 30 modules, 34 test files, ~5k lines.

What stays is the part that still earns its keep: the per-minute trigger
reads the alarm the phone published and starts morning-routine.service, so
the workout lock still runs at the right time.

THE ONE LOAD-BEARING BEHAVIOUR CHANGE. Deleting _alarm.py orphaned the only
writer of wake_state.json, and _trigger gated on it via
was_alarm_dismissed_today(). With that gone -- and was_workout_logged_today()
already permanently False, since WORKOUT_LOG_FILE does not exist -- both
gates would have been dead and the routine restarted on every tick for the
rest of the 60-minute catch-up window. The unit is Type=simple, so a start
while it runs is a no-op, but the moment the workout lock finished the next
tick would relaunch it on top of the user. So _trigger now writes its own
once-per-day latch (save_trigger_fired / was_trigger_fired_today) and gates
on that. Collapsing the catch-up window instead was rejected: that window is
the entire reason a suspended or just-booted machine still fires. It also
replaces the deleted sleep hook, via Persistent=true.

_trigger also imported should_refire from _alarm_session, which was on the
delete list. Since start_session lived only in _alarm.py, no session record
is ever written again and the branch was unreachable, not merely unused.

WAKE_STATE_FILE is now redirected in tests/conftest.py rather than per-file.
The trigger tests never redirected it, because until this commit nothing on
that path wrote anything -- and a per-file redirect is exactly the kind that
gets forgotten when a new writer appears, with a silent failure: a green
suite that quietly stamped the live state file. Verified by mtime that it
did not.

Cross-repo, same change: morning_routine._orchestrator loses its alarm leg
and its --with-alarm flag. Leaving an orchestrator pointing at a deleted
module caused a month-long silent failure once already.

uninstall.sh is new, and matters more than it looks. /usr/local/bin/shutdown
is root-owned and shadows /usr/bin/shutdown; deleting this repo would leave
it in place, and with the alarm cache gone it falls through to rtcwake +
hibernate -- so "shutdown now" would silently stop being a poweroff, every
day, forever. /etc/sudoers.d/wake-alarm is deliberately NOT removed:
screen-locker's own _wake_alarm.py still needs passwordless rtcwake.

159 tests pass at 100% branch coverage; all 26 pre-commit hooks green.
Verified by running it: _sync --show and _trigger --dry-run both resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
2026-08-30 15:24:02 +02:00

98 lines
2.7 KiB
Python

"""HMAC-signed record of whether the trigger already fired today.
The PC no longer wakes anyone, so this is not a record of a *dismissal* any
more — it is the trigger's once-per-day latch. It has to exist: the tick runs
every minute and the catch-up window is ``TRIGGER_CATCHUP_MINUTES`` wide, so
without a latch the morning routine would be restarted on every tick for the
rest of that window, relaunching the workout lock each time the user finished
it. The alarm's own dismissal used to provide that latch; deleting the alarm
without replacing it would have been a silent regression.
"""
from __future__ import annotations
from datetime import UTC, datetime
import json
import logging
from gatelock.log_integrity import (
compute_entry_hmac,
verify_entry_hmac,
)
from wake_alarm._constants import WAKE_STATE_FILE
_logger = logging.getLogger(__name__)
def _today_str() -> str:
"""Return today's date as YYYY-MM-DD in UTC."""
return datetime.now(tz=UTC).strftime("%Y-%m-%d")
def save_trigger_fired(*, fired_at: str) -> bool:
"""Record that the morning routine was started today.
Args:
fired_at: ISO timestamp of the tick that started the routine.
Returns:
True if saved successfully, False otherwise.
"""
entry: dict[str, object] = {
"date": _today_str(),
"fired_at": fired_at,
}
signature = compute_entry_hmac(entry)
if signature is not None:
entry["hmac"] = signature
else:
_logger.warning("HMAC key unavailable — saving unsigned wake state")
try:
with WAKE_STATE_FILE.open("w") as f:
json.dump(entry, f, indent=2)
except OSError as exc:
_logger.warning("Failed to save wake state: %s", exc)
return False
_logger.info("Recorded trigger fired at %s", fired_at)
return True
def load_wake_state() -> dict[str, object] | None:
"""Load and verify today's trigger record.
Returns the state dict if it exists, is valid (HMAC OK), and is
for today. Returns None otherwise.
"""
if not WAKE_STATE_FILE.exists():
return None
try:
with WAKE_STATE_FILE.open() as f:
state = json.load(f)
except OSError, json.JSONDecodeError:
_logger.warning("Cannot read wake state file")
return None
if not isinstance(state, dict):
return None
if state.get("date") != _today_str():
return None
if not verify_entry_hmac(state):
_logger.warning("Wake state HMAC verification failed")
return None
return state
def was_trigger_fired_today() -> bool:
"""Check whether the morning routine was already started today."""
state = load_wake_state()
if state is None:
return False
return state.get("fired_at") is not None