wake-alarm/wake_alarm/_countdown.py
Krzysztof kuhy Rudnicki 9e0cd123ff Phase 3: remove the max-fan ramp, split _alarm.py, wire the new modules
Item 14: the fan ramp is gone entirely. It caused anxiety without helping
anyone wake up. Deletes _max_fans/_restore_fans/_find_fan_hwmon, the
_AlarmHardware.fan_state field, both call sites, and their tests.
wake-alarm-fans.sh and its NOPASSWD sudoers entry come out in the install.sh
pass -- dropping a passwordless root helper is a bonus.

_alarm.py was 478 lines and the 250-line gate fires on any commit that stages
it, so this carries the split it has owed for a while. New modules, each a
real seam rather than an arbitrary cut:

  _gating          should-it-run-today + CLI (no time gate; that is _trigger's)
  _beep_loop       the sounding thread, now driving _escalation's planner
  _alarm_timers    self-rescheduling challenge refresh / skip window (mixin)
  _alarm_state     the progress + hardware dataclasses
  _rounds          challenge presentation, re-exporting the view helpers
  _countdown       skip-window arithmetic and demo/production intervals
  _dismiss_view    the post-dismissal banner
  _screen_flash    the ~1.3 Hz attention flash (kept under the 3 Hz threshold)
  _lock_setup      gatelock config + arbiter claim
  _startup         wake display, brightness, plug
  _tone            tone synthesis and cache (out of _audio)
  _brightness      xrandr brightness (was in _audio, never belonged there)

main() deliberately stays in _alarm.py: morning_routine/_orchestrator.py:39
hardcodes "wake_alarm._alarm", and moving it is the exact mistake that caused
a month-long silent production failure once already. Verified after the split
that the module still resolves and accepts --production.

Also fixes the plug pre-roll found while researching speakers: main() fired
turn_on_plug() and the first beep in the same instant, so an amplifier's
soft-start relay swallowed the opening of the alarm. PLUG_PREROLL_SECONDS=4
now sits between them.

Two bugs found by running the code rather than by lint:
- ruff stripped `struct` from the extracted _tone.py because the import
  landed before its first usage; only executing it caught the NameError.
- test fixtures patched _play_on_all_sinks/_set_max_brightness at
  wake_alarm._alarm, which silently stopped intercepting once those moved.

404 tests, 100% branch coverage, no new noqa/type-ignore suppressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-26 23:46:10 +02:00

93 lines
2.6 KiB
Python

"""Format the skip-window countdown shown on the lock screen.
Split out of ``_alarm.py`` for the 250-line cap, and because the wording is
load-bearing: once the window closes the label must stop implying a reward is
still available while making clear the alarm has *not* stopped.
"""
from __future__ import annotations
from wake_alarm._constants import DISMISS_CODE_REFRESH_SECONDS, DISMISS_WINDOW_MINUTES
# Demo mode compresses the schedule so a manual test does not take half an
# hour, without changing any production timing.
DEMO_REFRESH_MS = 10_000
DEMO_WINDOW_MS = 30_000
DEMO_WINDOW_SECONDS = 30.0
NO_SKIP_TEXT = "No skip available - type the code to stop the alarm"
WINDOW_CLOSED_INFO = "Skip window closed - type the code to stop the alarm"
NO_SKIP_STATUS = "No workout skip today."
def remaining_seconds(elapsed: float, window: float) -> float:
"""Seconds left in the skip window, never negative.
Args:
elapsed: Seconds since the alarm started.
window: Total length of the skip window in seconds.
Returns:
Remaining seconds, floored at zero.
"""
return max(0.0, window - elapsed)
def countdown_text(remaining: float, *, earnable: bool) -> str:
"""Build the timer label for the current state.
Args:
remaining: Seconds left in the skip window.
earnable: Whether a skip can still be earned.
Returns:
The label text.
"""
if not earnable or remaining <= 0:
return NO_SKIP_TEXT
minutes = int(remaining) // 60
seconds = int(remaining) % 60
return f"Skip window: {minutes:02d}:{seconds:02d}"
def refresh_interval_ms(*, demo_mode: bool) -> int:
"""Milliseconds between challenge refreshes.
Args:
demo_mode: True for the shortened manual-test schedule.
Returns:
The delay to pass to ``after()``.
"""
if demo_mode:
return DEMO_REFRESH_MS
return DISMISS_CODE_REFRESH_SECONDS * 1000
def skip_window_ms(*, demo_mode: bool) -> int:
"""Milliseconds until the workout-skip reward expires.
Args:
demo_mode: True for the shortened manual-test schedule.
Returns:
The delay to pass to ``after()``.
"""
if demo_mode:
return DEMO_WINDOW_MS
return DISMISS_WINDOW_MINUTES * 60 * 1000
def skip_window_seconds(*, demo_mode: bool) -> float:
"""Length of the skip window in seconds.
Args:
demo_mode: True for the shortened manual-test schedule.
Returns:
The window length.
"""
if demo_mode:
return DEMO_WINDOW_SECONDS
return float(DISMISS_WINDOW_MINUTES * 60)