wake-alarm/wake_alarm/_alarm.py
Krzysztof kuhy Rudnicki c1dbdcda22 Grade against 3.14 too, not just run on it
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading against 3.10/3.11 -- three tools judging an interpreter
nothing runs any more, and ruff withholding the pyupgrade fixes that only
become safe at the newer target. `pin_ci_python.py` rewrites these three knobs.

What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC`
is now correct (UP017), `socket.timeout` where `TimeoutError` is (UP041), and
ruff's stable formatter applying PEP 758 -- `except (A, B):` becomes
`except A, B:`. That makes the source 3.14-only at the syntax level, which is
what `requires-python = ">=3.14"` already promised; kuhy chose it deliberately
over holding ruff a release back (2026-08-29).

ruff, ruff-format, mypy --strict and pylint all clean at the new target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
2026-08-29 11:39:33 +02:00

250 lines
9.6 KiB
Python

"""Wake alarm: escalating beeps behind a fullscreen dismiss challenge.
Started by ``morning-routine.service``, never directly (AGENTS.md). Gate:
``_gating``. Sound: ``_beep_loop``. Timers: ``_alarm_timers`` and ``_alarm_flash``.
"""
from __future__ import annotations
import contextlib
from datetime import UTC, datetime
import logging
import sys
import threading
import tkinter as tk
from gatelock import (
GateRoot,
LockWindow,
SurfaceInfo,
)
from wake_alarm._alarm_display import _restore_display
from wake_alarm._alarm_flash import AlarmFlashMixin
from wake_alarm._alarm_session import finish_session, start_session
from wake_alarm._alarm_state import _AlarmHardware, _AlarmProgress
from wake_alarm._alarm_timers import AlarmTimersMixin
from wake_alarm._alarm_view import _build_alarm_view, _ViewGroup
from wake_alarm._beep_loop import start_beep_thread
from wake_alarm._challenges import (
_make_challenge,
)
from wake_alarm._constants import DISMISS_ROUNDS_REQUIRED
from wake_alarm._correlation import correlation_id, correlation_id_for_today
from wake_alarm._escape_flow import EscapeHatch, build_tracker
from wake_alarm._gating import parse_args, should_run_alarm
from wake_alarm._inhibit import (
release_power_key_inhibitor,
take_power_key_inhibitor,
)
from wake_alarm._lock_setup import build_config, claim_arbiter
from wake_alarm._logging_setup import configure_logging
from wake_alarm._reactivate import start_alarm_reactivation
from wake_alarm._rounds import (
BANNER_LINGER_MS,
normalise,
show_challenge,
show_dismissal,
show_round_progress,
show_wrong_answer,
)
from wake_alarm._sinks import (
_activate_all_outputs,
_restore_audio_state,
)
from wake_alarm._smart_plug import turn_off_plug
from wake_alarm._startup import prepare_hardware
from wake_alarm._state import (
save_wake_state,
)
_logger = logging.getLogger(__name__)
class WakeAlarm(AlarmTimersMixin, AlarmFlashMixin):
"""Fullscreen wake alarm with escalating beep and dismiss challenge."""
def __init__(
self, *, demo_mode: bool = False, correlation: str | None = None
) -> None:
"""Initialize the wake alarm.
Args:
demo_mode: If True, use a smaller window and shorter timers.
correlation: The morning's correlation id, derived from the
*resolved alarm instant* by the caller. Defaults to one
derived from now, which is only right for a demo run.
"""
self.demo_mode = demo_mode
self.dismissed = False
self._stop_beep = threading.Event()
self._active = True
self.root = GateRoot()
self.root.on_callback_error = self.on_callback_error
self.root.title("Wake Alarm" + (" [DEMO]" if demo_mode else ""))
config = build_config(demo_mode=demo_mode)
# A local, not an attribute: the arbiter is published here and then
# owned by LockWindow, which is what releases it on close.
arbiter = claim_arbiter(config)
# Built before setup(): setup() calls build_surface for every live
# output, and those need the challenge and the shared entry variable.
self._progress = _AlarmProgress(current_challenge=_make_challenge())
self._view = _ViewGroup(config, tk.StringVar(master=self.root))
self._escape = EscapeHatch(build_tracker(), self._view, self._on_escape_granted)
self._lock = LockWindow(self.root, config, hooks=self, arbiter=arbiter)
self._lock.setup()
self._update_timer()
if self._progress.current_challenge.kind == "flash":
self._start_flash_countdown()
self._schedule_code_refresh()
self._schedule_skip_window_close()
start_beep_thread(self._stop_beep, self._progress.started_at)
# Taken while the alarm is audible, released in on_close(). A short
# power-button press would otherwise end the alarm in one tap.
inhibitor = take_power_key_inhibitor()
# Written before anything can go wrong: a power cut from here on
# leaves a "ringing" record for _trigger to re-fire from on boot.
start_session(correlation or correlation_id(datetime.now().astimezone()))
self._hardware = _AlarmHardware(
audio_restore=_activate_all_outputs(), inhibitor=inhibitor
)
# The first activation above runs before the monitor has finished its
# HDMI handshake, so the sink that matters most may not exist yet.
# Keep retrying in the background until the alarm is dismissed -- only
# a fresh set-card-profile can bring that sink into existence, so no
# amount of re-enumerating sinks in the beep loop would find it.
start_alarm_reactivation(self._stop_beep)
self._flash_step()
self._lock.grab_input()
def build_surface(self, parent: tk.Misc, surface: SurfaceInfo) -> None:
"""Paint the full challenge on one monitor.
Called once per live output, and again whenever an output comes
back. Every copy shares one entry variable and one submit handler,
so answering on any monitor dismisses the alarm on all of them.
"""
view = _build_alarm_view(
parent,
self._progress.current_challenge,
self._view.config,
on_submit=self._on_submit,
entry_var=self._view.entry_var,
)
view.output_name = surface.output_name
view.escape_button.configure(command=self._escape.open)
self._view.add(view)
def teardown_surface(self, surface: SurfaceInfo) -> None:
"""Forget the view for an output that went dark."""
self._view.discard(surface.output_name)
def on_focus_ready(self, surface: SurfaceInfo | None) -> None:
"""Put keyboard focus on the dismiss-code entry once mapped.
``surface`` is None when no output is live -- the alarm is armed
and audible with nothing to show, so there is nothing to focus.
"""
if surface is None:
return
with contextlib.suppress(tk.TclError):
self._view.entry.focus_force()
def on_callback_error(self) -> None:
"""Surface an unexpected callback error without dropping the alarm."""
with contextlib.suppress(tk.TclError):
self._view.status_label.configure(
text="Something went wrong — try again.",
)
self._view.entry.focus_force()
def on_close(self) -> None:
"""Restore audio/display/plug; runs on every exit path, even SIGTERM."""
self._stop_beep.set()
# Closing the session here, not in _dismiss_alarm, so the escape hatch
# and a SIGTERM close it too. The one path that cannot reach this is
# the power cut it exists to catch.
finish_session()
release_power_key_inhibitor(self._hardware.inhibitor)
_restore_audio_state(self._hardware.audio_restore)
_restore_display()
turn_off_plug()
def _on_submit(self, _event: object = None) -> None:
"""Handle challenge submission.
Normalises input and compares to the current challenge answer.
Requires DISMISS_ROUNDS_REQUIRED correct entries in sequence — each
correct round generates a new random challenge so the user must stay
awake and re-engage each time.
"""
entered = normalise(self._view.entry.get())
if entered != self._progress.current_challenge.answer:
show_wrong_answer(self._view, self._progress.current_challenge)
if self._progress.current_challenge.kind == "flash":
self._start_flash_countdown()
return
self._progress.rounds_completed += 1
if self._progress.rounds_completed >= DISMISS_ROUNDS_REQUIRED:
self._dismiss_alarm(earned_skip=self._progress.skip_earnable)
return
self._progress.current_challenge = _make_challenge()
show_challenge(self._view, self._progress.current_challenge)
show_round_progress(
self._view,
completed=self._progress.rounds_completed,
required=DISMISS_ROUNDS_REQUIRED,
)
if self._progress.current_challenge.kind == "flash":
self._start_flash_countdown()
def _dismiss_alarm(self, *, earned_skip: bool) -> None:
"""Dismiss the alarm and save state."""
self._active = False
self.dismissed = True
self._stop_beep.set()
now_iso = datetime.now(tz=UTC).isoformat()
save_wake_state(dismissed_at=now_iso, skip_workout=earned_skip)
show_dismissal(
self._view.containers,
self._view.config,
earned_skip=earned_skip,
)
self.root.after(BANNER_LINGER_MS, self._lock.close)
def _on_escape_granted(self) -> None:
"""The hatch was earned: silence the alarm, grant no workout skip."""
_logger.warning("alarm dismissed via the escape hatch, no skip earned")
self._dismiss_alarm(earned_skip=False)
def run(self) -> None:
"""Start the alarm main loop, restoring hardware on every exit path."""
self._lock.run()
def main() -> None:
"""Entry point for the wake alarm daemon."""
configure_logging()
args = parse_args(sys.argv[1:])
if not args.trigger_now and not should_run_alarm():
return
_logger.warning(
"ALARM TRIGGERED at %s (demo=%s, trigger_now=%s)",
datetime.now(tz=UTC).isoformat(timespec="seconds"),
args.demo,
args.trigger_now,
)
prepare_hardware()
alarm = WakeAlarm(demo_mode=args.demo, correlation=correlation_id_for_today())
alarm.run()
if __name__ == "__main__":
main()