From 4eac1a45fe39cdbf98f1b753aa432a69e0b95e85 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 22 Jun 2026 12:47:06 +0200 Subject: [PATCH] Remove wake_alarm (extracted to kuhyx/wake-alarm) and fix the orchestrator Full history preserved via git filter-repo; production wake-alarm.service already cut over to the pip-installed standalone package and verified clean. Also fixes morning_routine._orchestrator, which has been silently broken since screen-locker's extraction on 2026-05-28: it still referenced python_pkg.screen_locker.screen_lock, causing ModuleNotFoundError on every morning the workout-lock handoff ran. Both module references now point at the pip-installed standalone packages (wake_alarm._alarm, screen_locker.screen_lock); fixing this required pip-installing screen_locker into system Python for the first time too (see the separate screen-locker packaging-fix commits), which it had never been. --- CLAUDE.md | 11 +- .../remove-wake-alarm-2026-06-22.json | 56 ++ python_pkg/morning_routine/_orchestrator.py | 20 +- python_pkg/morning_routine/install.sh | 27 +- python_pkg/wake_alarm/__init__.py | 1 - python_pkg/wake_alarm/_alarm.py | 496 ------------------ python_pkg/wake_alarm/_alarm_display.py | 76 --- python_pkg/wake_alarm/_audio.py | 493 ----------------- python_pkg/wake_alarm/_challenges.py | 129 ----- python_pkg/wake_alarm/_constants.py | 83 --- python_pkg/wake_alarm/_smart_plug.py | 155 ------ python_pkg/wake_alarm/_state.py | 129 ----- python_pkg/wake_alarm/install.sh | 131 ----- python_pkg/wake_alarm/shutdown-wrapper.sh | 38 -- python_pkg/wake_alarm/sleep-hook.sh | 30 -- python_pkg/wake_alarm/tests/__init__.py | 1 - python_pkg/wake_alarm/tests/test_alarm.py | 473 ----------------- .../wake_alarm/tests/test_alarm_audio.py | 420 --------------- .../wake_alarm/tests/test_alarm_challenges.py | 133 ----- .../wake_alarm/tests/test_alarm_display.py | 146 ------ .../wake_alarm/tests/test_alarm_part2.py | 440 ---------------- .../wake_alarm/tests/test_alarm_part3.py | 360 ------------- .../wake_alarm/tests/test_alarm_part4.py | 154 ------ .../wake_alarm/tests/test_alarm_sinks.py | 281 ---------- .../wake_alarm/tests/test_smart_plug.py | 351 ------------- python_pkg/wake_alarm/tests/test_state.py | 314 ----------- python_pkg/wake_alarm/wake-alarm-fans.sh | 63 --- python_pkg/wake_alarm/wake-alarm.service | 20 - python_pkg/wake_alarm/wake_state.json | 6 - 29 files changed, 79 insertions(+), 4958 deletions(-) create mode 100644 docs/superpowers/evidence/remove-wake-alarm-2026-06-22.json delete mode 100644 python_pkg/wake_alarm/__init__.py delete mode 100644 python_pkg/wake_alarm/_alarm.py delete mode 100644 python_pkg/wake_alarm/_alarm_display.py delete mode 100644 python_pkg/wake_alarm/_audio.py delete mode 100644 python_pkg/wake_alarm/_challenges.py delete mode 100644 python_pkg/wake_alarm/_constants.py delete mode 100644 python_pkg/wake_alarm/_smart_plug.py delete mode 100644 python_pkg/wake_alarm/_state.py delete mode 100755 python_pkg/wake_alarm/install.sh delete mode 100755 python_pkg/wake_alarm/shutdown-wrapper.sh delete mode 100755 python_pkg/wake_alarm/sleep-hook.sh delete mode 100644 python_pkg/wake_alarm/tests/__init__.py delete mode 100644 python_pkg/wake_alarm/tests/test_alarm.py delete mode 100644 python_pkg/wake_alarm/tests/test_alarm_audio.py delete mode 100644 python_pkg/wake_alarm/tests/test_alarm_challenges.py delete mode 100644 python_pkg/wake_alarm/tests/test_alarm_display.py delete mode 100644 python_pkg/wake_alarm/tests/test_alarm_part2.py delete mode 100644 python_pkg/wake_alarm/tests/test_alarm_part3.py delete mode 100644 python_pkg/wake_alarm/tests/test_alarm_part4.py delete mode 100644 python_pkg/wake_alarm/tests/test_alarm_sinks.py delete mode 100644 python_pkg/wake_alarm/tests/test_smart_plug.py delete mode 100644 python_pkg/wake_alarm/tests/test_state.py delete mode 100755 python_pkg/wake_alarm/wake-alarm-fans.sh delete mode 100644 python_pkg/wake_alarm/wake-alarm.service delete mode 100644 python_pkg/wake_alarm/wake_state.json diff --git a/CLAUDE.md b/CLAUDE.md index 5d5ab95..58c3241 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,7 @@ Extracted to their own repos: - [`steam-backlog-enforcer`](https://github.com/kuhyx/steam-backlog-enforcer) - [`screen-locker`](https://github.com/kuhyx/screen-locker) - [`diet-guard`](https://github.com/kuhyx/diet-guard) +- [`wake-alarm`](https://github.com/kuhyx/wake-alarm) Archived / unmaintained projects live in the sibling repository [`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive). @@ -34,16 +35,6 @@ Archived / unmaintained projects live in the sibling repository ### Python Packages (`python_pkg/`) -- **wake_alarm/** — Alarm + fan ramp + Tapo P110 smart plug control - - `_alarm.py` — alarm logic - - `_smart_plug.py` — Tapo P110 control - - `_state.py` — alarm state persistence - - `_constants.py` — timing/config constants - - `wake_state.json` — persistent alarm state - - `wake-alarm-fans.sh` — fan ramp script (requires sudo) - - `wake-alarm.service` — systemd unit - - `tests/` — pytest tests - - **brother_printer/** — Brother printer status checker via CUPS and USB/network query - `check_brother_printer.py` — main status check - `cups_queue.py` / `cups_service.py` — CUPS integration diff --git a/docs/superpowers/evidence/remove-wake-alarm-2026-06-22.json b/docs/superpowers/evidence/remove-wake-alarm-2026-06-22.json new file mode 100644 index 0000000..4328895 --- /dev/null +++ b/docs/superpowers/evidence/remove-wake-alarm-2026-06-22.json @@ -0,0 +1,56 @@ +{ + "intent": "Extract wake_alarm out of testsAndMisc into its own standalone repo (https://github.com/kuhyx/wake-alarm), remove it from this monorepo, and fix morning_routine's orchestrator which had been silently broken since screen-locker's extraction on 2026-05-28 (stale python_pkg.screen_locker.screen_lock module reference, causing ModuleNotFoundError every morning the workout lock handoff ran).", + "scope": [ + "Removed python_pkg/wake_alarm/ (source + tests, install.sh, systemd units, shell scripts)", + "Fixed python_pkg/morning_routine/_orchestrator.py's ALARM_MODULE/WORKOUT_LOCK_MODULE constants", + "Simplified python_pkg/morning_routine/install.sh (dropped the now-redundant sleep-hook install step, duplicated by wake_alarm's own installer)", + "Updated root CLAUDE.md's Architecture and 'Extracted to their own repos' sections", + "morning_routine itself stays in testsAndMisc (2-package glue script, no standalone value)" + ], + "changes": [ + "git filter-repo extraction into /tmp/wake-alarm-extract preserved full commit history (10 commits)", + "Rewrote python_pkg.wake_alarm imports to wake_alarm, vendored shared.logging_setup.configure_logging as wake_alarm/_logging_setup.py, untracked wake_state.json (runtime HMAC state, now gitignored)", + "Scaffolded standalone pyproject.toml/.pre-commit-config.yaml/CI by copying the already-corrected diet_guard scaffold (not screen-locker's, which still has the looser bar), plus a wave.Wave_write pylint generated-members fix this package's _audio.py needs", + "Pushed to https://github.com/kuhyx/wake-alarm, pip-installed into system Python's user site-packages, cut over wake-alarm.service to the pip-installed package (also fixed a config drift where the live unit was missing Environment=DISPLAY=:0 entirely)", + "Fixed _orchestrator.py: python_pkg.wake_alarm._alarm -> wake_alarm._alarm, python_pkg.screen_locker.screen_lock -> screen_locker.screen_lock", + "Discovered screen_locker had never been pip-installed (workout-locker.service relied entirely on WorkingDirectory/PYTHONPATH against the cloned repo); pip install -e failed until a packaging bug was fixed in screen-locker's own pyproject.toml ([tool.setuptools.packages.find] scope, blocked by the unrelated stronglift_replacement/ Flutter app sharing the repo) -- fixed in screen-locker commits 6b22b2e and 9489ee5, separately pushed", + "git rm -r python_pkg/wake_alarm/ from the monorepo" + ], + "verification": [ + { + "command": "cd /tmp/wake-alarm-extract && pre-commit run --all-files", + "result": "pass", + "evidence": "All hooks green: ruff, ruff-format, mypy, pylint (10.00/10), bandit, codespell, shellcheck, max-file-length" + }, + { + "command": "cd /tmp/wake-alarm-extract && pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100", + "result": "pass", + "evidence": "179 passed, TOTAL coverage 100.00% (692 stmts, 140 branches)" + }, + { + "command": "DISPLAY=:0 /usr/bin/python3 -m wake_alarm._alarm --demo --trigger-now", + "result": "pass", + "evidence": "User confirmed alarm window appeared; /run/wake-alarm-fans.state showed a fresh write with valid hwmon values (fan ramp ran via sudo -n, then auto-restored when the 30s demo window closed)" + }, + { + "command": "cd ~/testsAndMisc && PYTHONPATH=~/testsAndMisc DISPLAY=:0 /usr/bin/python3 -m python_pkg.morning_routine._orchestrator --with-alarm --production", + "result": "pass", + "evidence": "Ran wake_alarm._alarm (already dismissed today, exited 0) then screen_locker.screen_lock (workout already logged today per workout_log.json, exited 0) -- no ModuleNotFoundError for either, confirming the live orchestrator bug is fixed" + }, + { + "command": "cd ~/testsAndMisc && pytest python_pkg/ --cov=python_pkg --cov-branch --cov-fail-under=100", + "result": "pass", + "evidence": "415 passed, TOTAL coverage 100.00% after wake_alarm removal and the orchestrator fix" + } + ], + "risks": [ + "wake_alarm's hibernate/rtcwake wake cycle itself was not re-verified end-to-end (only --demo --trigger-now); real verification needs to wait for a natural ALARM_DAYS occurrence", + "screen_locker's pyproject.toml packaging fix was a necessary side-fix in a third, separately-versioned repo -- flagged to the user and pushed with separate confirmation, not silently bundled" + ], + "rollback": [ + "git revert this commit to restore python_pkg/wake_alarm/ and the old orchestrator module paths in the monorepo", + "Revert ~/.config/systemd/user/wake-alarm.service to the PYTHONPATH=%h/testsAndMisc / python -m python_pkg.wake_alarm._alarm version and systemctl --user daemon-reload", + "Revert ~/.config/systemd/user/workout-locker.service to the WorkingDirectory/PYTHONPATH version if reverting the screen_locker pip-install too", + "pip uninstall wake_alarm (and screen_locker, if reverting that leg) from system Python if reverting fully" + ] +} diff --git a/python_pkg/morning_routine/_orchestrator.py b/python_pkg/morning_routine/_orchestrator.py index 7cfbb1c..eb8a659 100644 --- a/python_pkg/morning_routine/_orchestrator.py +++ b/python_pkg/morning_routine/_orchestrator.py @@ -1,10 +1,11 @@ """Orchestrate the morning wake/workout flow as one sequential routine. -The wake alarm (``python_pkg.wake_alarm``) and the workout screen lock -(``python_pkg.screen_locker``) used to run as two independent -``graphical-session.target`` user services, each opening its own fullscreen -``-topmost`` Tk window. On a wake morning they could grab the screen at the same -time, so the alarm could end up hidden behind the workout lock (or vice versa). +The wake alarm (``wake_alarm``, https://github.com/kuhyx/wake-alarm) and the +workout screen lock (``screen_locker``, https://github.com/kuhyx/screen-locker) +used to run as two independent ``graphical-session.target`` user services, +each opening its own fullscreen ``-topmost`` Tk window. On a wake morning they +could grab the screen at the same time, so the alarm could end up hidden +behind the workout lock (or vice versa). This orchestrator makes them one coherent flow by running them as **sequential subprocesses**: the alarm runs first and owns the fullscreen until it is @@ -13,6 +14,11 @@ at a time, so they can never collide. Each subprocess still self-gates (the alarm only fires on alarm days when undismissed; the lock exits if a skip was earned or the workout is already logged), so this is safe to run on every wake. +Both ``wake_alarm`` and ``screen_locker`` are pip-installed into system +Python's user site-packages (each repo's own install.sh does this), so +``python -m `` resolves them with no extra ``PYTHONPATH``/``cwd`` +plumbing here. + Usage: python -m python_pkg.morning_routine._orchestrator --with-alarm # resume python -m python_pkg.morning_routine._orchestrator # lock only @@ -30,8 +36,8 @@ from python_pkg.shared.logging_setup import configure_logging _logger = logging.getLogger(__name__) # Modules invoked as ``python -m --production``. -ALARM_MODULE: str = "python_pkg.wake_alarm._alarm" -WORKOUT_LOCK_MODULE: str = "python_pkg.screen_locker.screen_lock" +ALARM_MODULE: str = "wake_alarm._alarm" +WORKOUT_LOCK_MODULE: str = "screen_locker.screen_lock" def _run_module(module: str) -> int: diff --git a/python_pkg/morning_routine/install.sh b/python_pkg/morning_routine/install.sh index 8c6b437..1c2672e 100755 --- a/python_pkg/morning_routine/install.sh +++ b/python_pkg/morning_routine/install.sh @@ -3,41 +3,32 @@ # # What it does: # 1. Installs morning-routine.service (user service, started on resume). -# 2. Reinstalls the systemd-sleep hook so resume starts morning-routine -# (alarm first, then the workout lock - one fullscreen owner at a time). -# 3. Disables the standalone wake-alarm.service autostart: the orchestrator +# 2. Disables the standalone wake-alarm.service autostart: the orchestrator # runs the alarm now, and this also removes its evening-login firing quirk. -# 4. Leaves workout-locker.service + the early-bird timer for login / 08:30. +# 3. Leaves workout-locker.service + the early-bird timer for login / 08:30. # -# Prereq: run python_pkg/wake_alarm/install.sh first for the rtcwake/fan -# sudoers entries, the fan-control script, and python-kasa. +# Prereq: run wake_alarm's own install.sh first (https://github.com/kuhyx/wake-alarm) — +# it installs the rtcwake/fan sudoers entries, python-kasa, and the +# systemd-sleep hook that starts morning-routine.service on resume. This +# script does not duplicate that hook install. set -euo pipefail SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" -REPO_ROOT="$(readlink -f "$SCRIPT_DIR/../..")" SERVICE_SRC="$SCRIPT_DIR/morning-routine.service" -SLEEP_HOOK_SRC="$REPO_ROOT/python_pkg/wake_alarm/sleep-hook.sh" SYSTEMD_USER_DIR="$HOME/.config/systemd/user" -SLEEP_HOOK_DST="/usr/lib/systemd/system-sleep/wake-alarm.sh" echo "=== Unified Morning Routine Installer ===" # 1. Install the orchestrator user service. -echo "[1/3] Installing morning-routine.service..." +echo "[1/2] Installing morning-routine.service..." mkdir -p "$SYSTEMD_USER_DIR" cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/morning-routine.service" systemctl --user daemon-reload echo " Installed to $SYSTEMD_USER_DIR/morning-routine.service" -# 2. Reinstall the sleep hook (now starts morning-routine.service on resume). -echo "[2/3] Installing systemd-sleep hook (requires sudo)..." -sudo cp "$SLEEP_HOOK_SRC" "$SLEEP_HOOK_DST" -sudo chmod 0755 "$SLEEP_HOOK_DST" -echo " Installed to $SLEEP_HOOK_DST" - -# 3. Disable the standalone wake-alarm.service autostart (orchestrator owns it). -echo "[3/3] Disabling standalone wake-alarm.service autostart..." +# 2. Disable the standalone wake-alarm.service autostart (orchestrator owns it). +echo "[2/2] Disabling standalone wake-alarm.service autostart..." if systemctl --user cat wake-alarm.service &>/dev/null; then systemctl --user disable wake-alarm.service 2>/dev/null || true systemctl --user stop wake-alarm.service 2>/dev/null || true diff --git a/python_pkg/wake_alarm/__init__.py b/python_pkg/wake_alarm/__init__.py deleted file mode 100644 index 324280d..0000000 --- a/python_pkg/wake_alarm/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Weekend wake alarm system with escalating beep and dismiss challenge.""" diff --git a/python_pkg/wake_alarm/_alarm.py b/python_pkg/wake_alarm/_alarm.py deleted file mode 100644 index c74df62..0000000 --- a/python_pkg/wake_alarm/_alarm.py +++ /dev/null @@ -1,496 +0,0 @@ -"""Weekend wake alarm daemon with escalating beep and dismiss challenge. - -Run as a systemd service on boot. Checks if today is an alarm day, -plays escalating system beeps, and presents a fullscreen dismiss -challenge (random code typing). Dismissing within the window grants a -workout-free day via HMAC-signed wake state. -""" - -from __future__ import annotations - -import argparse -import contextlib -from dataclasses import dataclass -from datetime import datetime, timezone -import logging -import sys -import threading -import time -import tkinter as tk - -from gatelock import GateRoot, LockConfig, LockWindow - -from python_pkg.shared.logging_setup import configure_logging -from python_pkg.wake_alarm._alarm_display import _restore_display, _wake_display -from python_pkg.wake_alarm._audio import ( - _activate_alarm_audio, - _beep_loud, - _beep_medium, - _beep_soft, - _max_fans, - _play_on_extra_devices, - _restore_alarm_audio, - _restore_fans, - _set_max_brightness, - _warn_if_no_real_sink, -) -from python_pkg.wake_alarm._challenges import ( - _Challenge, - _make_challenge, -) -from python_pkg.wake_alarm._constants import ( - ALARM_DAYS, - DISMISS_CODE_REFRESH_SECONDS, - DISMISS_FLASH_SECONDS, - DISMISS_ROUNDS_REQUIRED, - DISMISS_WINDOW_MINUTES, - DISPLAY_WAKE_WAIT_SECONDS, - LOUD_TOGGLE_INTERVAL, - MEDIUM_BEEP_INTERVAL, - PHASE_MEDIUM_END, - PHASE_SOFT_END, - SOFT_BEEP_INTERVAL, -) -from python_pkg.wake_alarm._smart_plug import turn_off_plug, turn_on_plug -from python_pkg.wake_alarm._state import ( - save_wake_state, - was_alarm_dismissed_today, - was_workout_logged_today, -) - -_logger = logging.getLogger(__name__) - - -def _is_alarm_day() -> bool: - """Check if today is an alarm day.""" - return datetime.now(tz=timezone.utc).weekday() in ALARM_DAYS - - -@dataclass -class _AlarmView: - """The Tk widgets that make up the alarm's dismiss-challenge screen.""" - - container: tk.Frame - title_label: tk.Label - round_label: tk.Label - info_label: tk.Label - code_label: tk.Label - entry: tk.Entry - status_label: tk.Label - timer_label: tk.Label - - -@dataclass -class _AlarmProgress: - """Mutable dismiss-challenge progress state.""" - - current_challenge: _Challenge - skip_earnable: bool = True - rounds_completed: int = 0 - flash_remaining: int = 0 - flash_on: bool = False - - -@dataclass -class _AlarmHardware: - """Hardware state captured at alarm start, restored when it closes.""" - - fan_state: bool - audio_restore: str | None - - -class WakeAlarm: - """Fullscreen wake alarm with escalating beep and dismiss challenge.""" - - def __init__(self, *, demo_mode: bool = False) -> None: - """Initialize the wake alarm. - - Args: - demo_mode: If True, use a smaller window and shorter timers. - """ - self.demo_mode = demo_mode - self.dismissed = False - self._stop_beep = threading.Event() - self._alarm_start: float = time.monotonic() - 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 "")) - # mode="soft": no watchdog exists yet for a clean, silent lock - # failure, so hard-lock (overrideredirect + grab) is deferred. - self._lock = LockWindow(self.root, LockConfig(mode="soft"), hooks=self) - self._lock.setup() - - self._progress = _AlarmProgress(current_challenge=_make_challenge()) - self._view = self._build_ui() - self._update_timer() - if self._progress.current_challenge.kind == "flash": - self._start_flash_countdown() - self._schedule_code_refresh() - self._schedule_skip_window_close() - self._start_beep_thread() - self._hardware = _AlarmHardware( - fan_state=_max_fans(), audio_restore=_activate_alarm_audio() - ) - self._start_screen_flash() - self._lock.grab_input() - - def on_focus_ready(self) -> None: - """Put keyboard focus on the dismiss-code entry once mapped.""" - 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 fans/audio/display/plug; runs on every exit path, even SIGTERM.""" - self._stop_beep.set() - _restore_fans(active=self._hardware.fan_state) - _restore_alarm_audio(self._hardware.audio_restore) - _restore_display() - turn_off_plug() - - def _build_ui(self) -> _AlarmView: - """Build the dismiss-challenge UI and return its widgets as a view.""" - challenge = self._progress.current_challenge - - container = tk.Frame(self.root, bg="#1a1a1a") - container.place(relx=0.5, rely=0.5, anchor="center") - - title_label = tk.Label( - container, - text="WAKE UP!", - font=("Arial", 48, "bold"), - fg="#ff4444", - bg="#1a1a1a", - ) - title_label.pack(pady=20) - - round_label = tk.Label( - container, - text=f"Round 1 / {DISMISS_ROUNDS_REQUIRED}", - font=("Arial", 24, "bold"), - fg="#ffaa00", - bg="#1a1a1a", - ) - round_label.pack(pady=5) - - info_label = tk.Label( - container, - text=challenge.hint, - font=("Arial", 18), - fg="white", - bg="#1a1a1a", - ) - info_label.pack(pady=10) - - # Math and sort use a smaller font because their display text is wider. - code_font_size = 48 if challenge.kind in ("math", "sort") else 72 - code_label = tk.Label( - container, - text=challenge.display, - font=("Courier", code_font_size, "bold"), - fg="#00ff00", - bg="#1a1a1a", - ) - code_label.pack(pady=30) - - entry = tk.Entry( - container, - font=("Courier", 36), - justify="center", - width=12, - ) - entry.pack(pady=10) - entry.focus_set() - entry.bind("", self._on_submit) - - status_label = tk.Label( - container, - text="", - font=("Arial", 18), - fg="#ff4444", - bg="#1a1a1a", - ) - status_label.pack(pady=10) - - timer_label = tk.Label( - container, - text="", - font=("Arial", 14), - fg="#aaaaaa", - bg="#1a1a1a", - ) - timer_label.pack(pady=5) - - return _AlarmView( - container=container, - title_label=title_label, - round_label=round_label, - info_label=info_label, - code_label=code_label, - entry=entry, - status_label=status_label, - timer_label=timer_label, - ) - - 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 = self._view.entry.get().strip().upper() - if entered != self._progress.current_challenge.answer: - self._view.status_label.configure(text="Wrong! Try again.") - self._view.entry.delete(0, tk.END) - if self._progress.current_challenge.kind == "flash": - self._view.code_label.configure( - text=self._progress.current_challenge.display, - fg="#00ff00", - ) - 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() - self._view.code_label.configure( - text=self._progress.current_challenge.display, - fg="#00ff00", - ) - self._view.info_label.configure(text=self._progress.current_challenge.hint) - self._view.entry.delete(0, tk.END) - next_round = self._progress.rounds_completed + 1 - self._view.round_label.configure( - text=f"Round {next_round} / {DISMISS_ROUNDS_REQUIRED}", - ) - self._view.status_label.configure( - text=f"Round {self._progress.rounds_completed} done — keep going!", - ) - if self._progress.current_challenge.kind == "flash": - self._start_flash_countdown() - - def _start_flash_countdown(self) -> None: - """Begin the flash countdown: show code then hide it.""" - self._progress.flash_remaining = DISMISS_FLASH_SECONDS - self._flash_tick() - - def _flash_tick(self) -> None: - """Decrement flash countdown; replace the displayed code with placeholders.""" - if not self._active: - return - if self._progress.flash_remaining > 0: - self._view.status_label.configure( - text=f"Memorise! Hiding in {self._progress.flash_remaining}s…", - ) - self._progress.flash_remaining -= 1 - self.root.after(1000, self._flash_tick) - else: - hidden = "?" * len(self._progress.current_challenge.display) - self._view.code_label.configure(text=hidden, fg="#555555") - self._view.status_label.configure(text="Now type the code from memory!") - - 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=timezone.utc).isoformat() - save_wake_state(dismissed_at=now_iso, skip_workout=earned_skip) - - for widget in self._view.container.winfo_children(): - widget.destroy() - - msg = ( - "Workout skip earned! Enjoy your morning." - if earned_skip - else "Alarm dismissed. No workout skip." - ) - color = "#00ff00" if earned_skip else "#ffaa00" - - tk.Label( - self._view.container, - text=msg, - font=("Arial", 36, "bold"), - fg=color, - bg="#1a1a1a", - ).pack(pady=30) - - self.root.after(3000, self._lock.close) - - def _schedule_code_refresh(self) -> None: - """Replace the current challenge periodically. - - Ensures the user can't simply wait out a hard challenge type — a new - random challenge is generated every DISMISS_CODE_REFRESH_SECONDS. - """ - if not self._active: - return - self._progress.current_challenge = _make_challenge() - self._view.code_label.configure( - text=self._progress.current_challenge.display, - fg="#00ff00", - ) - self._view.info_label.configure(text=self._progress.current_challenge.hint) - self._view.entry.delete(0, tk.END) - if self._progress.current_challenge.kind == "flash": - self._start_flash_countdown() - ms = DISMISS_CODE_REFRESH_SECONDS * 1000 if not self.demo_mode else 10_000 - self.root.after(ms, self._schedule_code_refresh) - - def _schedule_skip_window_close(self) -> None: - """Mark the workout-skip reward as expired after the allowed time.""" - ms = DISMISS_WINDOW_MINUTES * 60 * 1000 if not self.demo_mode else 30_000 - self.root.after(ms, self._on_skip_window_expired) - - def _on_skip_window_expired(self) -> None: - """Skip window closed: keep the alarm running, deny the workout skip. - - The alarm intentionally does NOT stop here - it keeps beeping and - flashing until the user actually types the code. Only the workout-skip - reward expires; dismissing now silences the alarm without earning a skip. - """ - if not self._active: - return - self._progress.skip_earnable = False - self._view.info_label.configure( - text="Skip window closed - type the code to stop the alarm", - ) - self._view.status_label.configure(text="No workout skip today.") - _logger.info("Skip window expired - alarm continues until dismissed.") - - def _update_timer(self) -> None: - """Show the skip-window countdown, then a keep-going silence prompt.""" - if not self._active: - return - elapsed = time.monotonic() - self._alarm_start - window = DISMISS_WINDOW_MINUTES * 60 if not self.demo_mode else 30 - remaining = max(0, window - elapsed) - if self._progress.skip_earnable and remaining > 0: - minutes = int(remaining) // 60 - seconds = int(remaining) % 60 - self._view.timer_label.configure( - text=f"Skip window: {minutes:02d}:{seconds:02d}", - ) - else: - self._view.timer_label.configure( - text="No skip available - type the code to stop the alarm", - ) - self.root.after(1000, self._update_timer) - - def _start_beep_thread(self) -> None: - """Start the background beep escalation thread.""" - thread = threading.Thread(target=self._beep_loop, daemon=True) - thread.start() - - def _start_screen_flash(self) -> None: - """Start flashing the screen background to attract attention.""" - self._flash_step() - - def _flash_step(self) -> None: - """Alternate background colour every 750 ms (below seizure-risk 3 Hz).""" - if not self._active: - return - self.root.configure(bg="#ff0000" if self._progress.flash_on else "#1a1a1a") - self._progress.flash_on = not self._progress.flash_on - self.root.after(750, self._flash_step) - - def _beep_loop(self) -> None: - """Escalating beep loop running in background thread.""" - while not self._stop_beep.is_set(): - elapsed_minutes = (time.monotonic() - self._alarm_start) / 60.0 - - if elapsed_minutes < PHASE_SOFT_END: - _play_on_extra_devices(440) - _beep_soft() - self._stop_beep.wait(SOFT_BEEP_INTERVAL) - elif elapsed_minutes < PHASE_MEDIUM_END: - _play_on_extra_devices(1000) - _beep_medium() - self._stop_beep.wait(MEDIUM_BEEP_INTERVAL) - else: - freq = 800 if int(elapsed_minutes * 10) % 2 == 0 else 1200 - _play_on_extra_devices(freq) - _beep_loud(freq) - self._stop_beep.wait(LOUD_TOGGLE_INTERVAL) - - def run(self) -> None: - """Start the alarm main loop, restoring hardware on every exit path.""" - self._lock.run() - - -def _should_run_alarm() -> bool: - """Determine if the alarm should run right now.""" - if not _is_alarm_day(): - _logger.info("Not an alarm day. Exiting.") - return False - if was_alarm_dismissed_today(): - _logger.info("Alarm already dismissed today. Exiting.") - return False - if was_workout_logged_today(): - _logger.info("Workout already logged today. Skipping alarm.") - return False - return True - - -def _parse_args(argv: list[str]) -> argparse.Namespace: - """Parse CLI arguments for the alarm daemon.""" - parser = argparse.ArgumentParser(description="Wake alarm daemon.") - parser.add_argument( - "--production", - action="store_true", - help="Production mode (default; kept for systemd compatibility).", - ) - parser.add_argument( - "--demo", - action="store_true", - help="Run with a smaller window and shorter timers.", - ) - parser.add_argument( - "--trigger-now", - action="store_true", - help="Bypass the day/dismiss gate and fire the alarm immediately.", - ) - return parser.parse_args(argv) - - -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=timezone.utc).isoformat(timespec="seconds"), - args.demo, - args.trigger_now, - ) - _warn_if_no_real_sink() - _wake_display() - # Wait for the G27Q to power on and enumerate its HDMI audio sink. - # Without this delay the sink often isn't visible yet when _activate_alarm_audio - # runs, making the alarm silent when the monitor was physically off at wake time. - time.sleep(DISPLAY_WAKE_WAIT_SECONDS) - _set_max_brightness() - turn_on_plug() - alarm = WakeAlarm(demo_mode=args.demo) - alarm.run() - - -if __name__ == "__main__": - main() diff --git a/python_pkg/wake_alarm/_alarm_display.py b/python_pkg/wake_alarm/_alarm_display.py deleted file mode 100644 index ea952de..0000000 --- a/python_pkg/wake_alarm/_alarm_display.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Display power and screensaver helpers for the wake alarm. - -Wakes monitors that may be physically powered off (via DDC/CI) or in DPMS -standby, and restores the screensaver once the alarm dismiss flow ends. -""" - -from __future__ import annotations - -import logging -import shutil -import subprocess - -_logger = logging.getLogger(__name__) - - -def _ddcutil_power_on() -> None: - """Power on all connected monitors via DDC/CI VCP code D6. - - This wakes monitors that were physically turned off with the power button - and therefore ignore DPMS signals. Falls back silently when ddcutil is - absent or returns an error (e.g. no i2c access yet). - """ - ddcutil = shutil.which("ddcutil") - if ddcutil is None: - _logger.warning("ddcutil not on PATH; skipping DDC/CI monitor power-on") - return - try: - result = subprocess.run( - [ddcutil, "setvcp", "D6", "01"], - check=False, - capture_output=True, - timeout=10, - ) - except (OSError, subprocess.TimeoutExpired): - _logger.warning("ddcutil setvcp failed", exc_info=True) - return - if result.returncode != 0: - _logger.warning( - "ddcutil setvcp D6 01 exited %d: %s", - result.returncode, - result.stderr.decode(errors="replace").strip()[:200], - ) - else: - _logger.info("DDC/CI monitor power-on sent") - - -def _wake_display() -> None: - """Force the display on and disable screensaver during alarm. - - Sends both a DDC/CI hard power-on (for monitors powered off via the - power button) and a DPMS force-on (for monitors in standby). - """ - _ddcutil_power_on() - xset = shutil.which("xset") - if xset is None: - _logger.warning("xset not on PATH; skipping DPMS display wake") - return - for cmd in ( - [xset, "dpms", "force", "on"], - [xset, "s", "off"], - ): - subprocess.run(cmd, check=False, capture_output=True, timeout=5) - - -def _restore_display() -> None: - """Re-enable screensaver after the alarm ends.""" - xset = shutil.which("xset") - if xset is None: - _logger.warning("xset not on PATH; skipping display restore") - return - subprocess.run( - [xset, "s", "on"], - check=False, - capture_output=True, - timeout=5, - ) diff --git a/python_pkg/wake_alarm/_audio.py b/python_pkg/wake_alarm/_audio.py deleted file mode 100644 index 54c977a..0000000 --- a/python_pkg/wake_alarm/_audio.py +++ /dev/null @@ -1,493 +0,0 @@ -"""Audio playback, fan control, and PipeWire sink management for the wake alarm.""" - -from __future__ import annotations - -import logging -import math -import os -from pathlib import Path -import shutil -import struct -import subprocess -import sys -import tempfile -import time -import wave - -from python_pkg.wake_alarm._constants import ( - ALARM_AUDIO_CARD, - ALARM_AUDIO_PROFILE, - ALARM_AUDIO_SINK, - ALARM_AUDIO_SINK_POLL_SECONDS, - ALARM_AUDIO_SINK_WAIT_SECONDS, -) - -_logger = logging.getLogger(__name__) - -_TONE_CACHE: dict[int, Path] = {} -_TONE_TIMEOUT_SECONDS: float = 6.0 -_TONE_DURATION_SECONDS: float = 1.5 -_TONE_FRAMERATE: int = 48000 -_TONE_AMPLITUDE: int = 32760 # near s16 max for the loudest sine we can emit - -# Number of back-to-back PC-speaker beeps per _play_tone call. -# pcspkr volume is hardware-fixed, so we lean on repetition + duration to be -# loud enough to actually wake the user. -_PCSPKR_REPEATS: int = 3 -_PCSPKR_GAP_SECONDS: float = 0.12 - -# Motherboard PC speaker exposed by the pcspkr kernel module. -# Writing EV_SND/SND_TONE input_event structs makes it beep — bypasses -# PipeWire/ALSA entirely, so it stays audible even when no real sink exists. -_PCSPKR_DEVICE: str = "/dev/input/by-path/platform-pcspkr-event-spkr" -_PCSPKR_EV_SND: int = 0x12 -_PCSPKR_SND_TONE: int = 0x02 -# struct input_event: timeval (long sec, long usec), u16 type, u16 code, s32 val -_PCSPKR_EVENT_FMT: str = "llHHi" - -# Extra PipeWire sinks to always play alarm audio on (alongside the default). -# alsa_output...hdmi-stereo = GA102 → G27Q (has built-in speaker, always on). -_EXTRA_PIPEWIRE_SINKS: tuple[str, ...] = ("alsa_output.pci-0000_01_00.1.hdmi-stereo",) - -# NCT Super I/O chip names that expose a single pwm1 fan control channel. -_NCT_CHIP_NAMES: frozenset[str] = frozenset( - { - "nct6775", - "nct6779", - "nct6791", - "nct6792", - "nct6793", - "nct6795", - "nct6796", - "nct6797", - "nct6798", - "nct6799", - } -) - -# Installed by install.sh, controlled via sudoers NOPASSWD entry. -_FAN_SCRIPT: str = "/usr/local/bin/wake-alarm-fans.sh" -_SUDO_BIN: str = "/usr/bin/sudo" - - -def _beep_soft() -> None: - """Play a soft system beep via terminal bell.""" - sys.stdout.write("\a") - sys.stdout.flush() - - -def _speaker_test_path() -> str: - """Resolve absolute path to speaker-test binary.""" - path = shutil.which("speaker-test") - if path is None: - msg = "speaker-test not found on PATH" - raise FileNotFoundError(msg) - return path - - -def _beep_pcspkr(frequency: int, duration_seconds: float) -> None: - """Beep the motherboard PC speaker via evdev (audible without any sink). - - Silently no-ops when the device is missing or unwritable so the call is - always safe from the alarm hot path. - """ - try: - # buffering=0 so the write hits the device immediately. - with Path(_PCSPKR_DEVICE).open("wb", buffering=0) as dev: - dev.write( - struct.pack( - _PCSPKR_EVENT_FMT, - 0, - 0, - _PCSPKR_EV_SND, - _PCSPKR_SND_TONE, - int(frequency), - ), - ) - time.sleep(duration_seconds) - dev.write( - struct.pack( - _PCSPKR_EVENT_FMT, - 0, - 0, - _PCSPKR_EV_SND, - _PCSPKR_SND_TONE, - 0, - ), - ) - except OSError: - _logger.warning( - "PC speaker beep at %d Hz failed (device %s)", - frequency, - _PCSPKR_DEVICE, - exc_info=True, - ) - - -def _ensure_tone_wav(frequency: int) -> Path: - """Generate (and cache) a mono 48 kHz sine WAV at *frequency* Hz.""" - cached = _TONE_CACHE.get(frequency) - if cached is not None and cached.exists(): - return cached - path = Path(tempfile.gettempdir()) / f"wake_alarm_tone_{frequency}.wav" - n_frames = int(_TONE_FRAMERATE * _TONE_DURATION_SECONDS) - with wave.open(str(path), "wb") as wav: - wav.setnchannels(1) - wav.setsampwidth(2) - wav.setframerate(_TONE_FRAMERATE) - frames = bytearray() - for i in range(n_frames): - sample = int( - _TONE_AMPLITUDE - * math.sin(2 * math.pi * frequency * i / _TONE_FRAMERATE), - ) - frames.extend(struct.pack(" bool: - """Run *binary* on *wav* with a generous timeout. Return True on success.""" - path = shutil.which(binary) - if path is None: - return False - try: - result = subprocess.run( - [path, str(wav)], - capture_output=True, - timeout=_TONE_TIMEOUT_SECONDS, - check=False, - ) - except (OSError, subprocess.TimeoutExpired): - _logger.warning("%s failed playing %s", binary, wav.name, exc_info=True) - return False - if result.returncode != 0: - _logger.warning( - "%s exited %d for %s: %s", - binary, - result.returncode, - wav.name, - result.stderr.decode(errors="replace").strip()[:200], - ) - return False - return True - - -def _play_tone(frequency: int) -> None: - """Play a sine tone via paplay/aplay/speaker-test, fall back to soft beep. - - Always also beeps the motherboard PC speaker (multiple times) so the - alarm stays loud and audible even when PipeWire only has the auto_null - sink. - """ - for i in range(_PCSPKR_REPEATS): - _beep_pcspkr(frequency, _TONE_DURATION_SECONDS) - if i < _PCSPKR_REPEATS - 1: - time.sleep(_PCSPKR_GAP_SECONDS) - try: - wav = _ensure_tone_wav(frequency) - except OSError: - _logger.warning( - "Could not generate tone WAV at %d Hz; using soft beep", - frequency, - exc_info=True, - ) - _beep_soft() - return - for binary in ("paplay", "aplay"): - if _try_player(binary, wav): - return - try: - subprocess.run( - [ - _speaker_test_path(), - "-t", - "sine", - "-f", - str(frequency), - "-l", - "1", - ], - capture_output=True, - timeout=_TONE_TIMEOUT_SECONDS, - check=False, - ) - except (FileNotFoundError, OSError, subprocess.TimeoutExpired): - _logger.warning( - "All tone players failed at %d Hz; falling back to soft beep", - frequency, - exc_info=True, - ) - _beep_soft() - - -def _play_on_extra_devices(frequency: int) -> None: - """Fire-and-forget: play a sine tone on each extra PipeWire sink.""" - try: - path = _speaker_test_path() - except FileNotFoundError: - _logger.warning("speaker-test missing; skipping extra-device beep") - return - for sink in _EXTRA_PIPEWIRE_SINKS: - _play_tone_on_sink(path, sink, frequency) - - -def _play_tone_on_sink(path: str, sink: str, frequency: int) -> None: - """Launch speaker-test for *sink*; log a warning on OSError.""" - try: - subprocess.Popen( - [path, "-t", "sine", "-f", str(frequency), "-l", "1"], - env={**os.environ, "PIPEWIRE_NODE": sink}, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except OSError: - _logger.warning("Failed to play tone on sink %s", sink, exc_info=True) - - -def _find_fan_hwmon() -> str | None: - """Return the hwmon directory for an NCT fan controller, or None.""" - for name_path in Path("/sys/class/hwmon").glob("hwmon*/name"): - try: - chip = name_path.read_text().strip() - except OSError: - _logger.warning("Could not read %s", name_path, exc_info=True) - continue - if chip in _NCT_CHIP_NAMES: - return str(name_path.parent) - _logger.warning( - "No NCT super-I/O hwmon entry found; fan ramp will be skipped", - ) - return None - - -def _max_fans() -> bool: - """Ramp every NCT pwm channel to 100% speed via the helper script. - - The helper records prior state under /run/wake-alarm-fans.state so - _restore_fans() can put things back without arguments. Safe: higher fan - speed only lowers temperatures, never damages hardware. - - Returns: - True when the ramp script ran successfully, False otherwise. - """ - if _find_fan_hwmon() is None: - return False - try: - result = subprocess.run( - [_SUDO_BIN, "-n", _FAN_SCRIPT, "max"], - check=False, - capture_output=True, - timeout=5, - ) - except (OSError, subprocess.TimeoutExpired): - _logger.warning( - "Fan script %s not runnable; skipping fan ramp", - _FAN_SCRIPT, - exc_info=True, - ) - return False - if result.returncode != 0: - _logger.warning( - "Fan script %s exited %d: %s", - _FAN_SCRIPT, - result.returncode, - result.stderr.decode(errors="replace").strip(), - ) - return False - return True - - -def _restore_fans(*, active: bool) -> None: - """Restore fan speed if _max_fans() previously succeeded.""" - if not active: - return - try: - subprocess.run( - [_SUDO_BIN, "-n", _FAN_SCRIPT, "restore"], - check=False, - capture_output=True, - timeout=5, - ) - except (OSError, subprocess.TimeoutExpired): - _logger.warning( - "Failed to restore fan state via %s", - _FAN_SCRIPT, - exc_info=True, - ) - - -def _set_max_brightness() -> None: - """Set all connected monitors to maximum brightness via xrandr.""" - xrandr = shutil.which("xrandr") - if xrandr is None: - _logger.warning("xrandr not on PATH; skipping max-brightness") - return - try: - result = subprocess.run( - [xrandr, "--query"], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - except (OSError, subprocess.TimeoutExpired): - _logger.warning("xrandr --query failed; skipping max-brightness", exc_info=True) - return - for line in result.stdout.splitlines(): - if " connected" in line: - output = line.split()[0] - try: - subprocess.run( - [xrandr, "--output", output, "--brightness", "1.0"], - check=False, - capture_output=True, - timeout=5, - ) - except (OSError, subprocess.TimeoutExpired): - _logger.warning( - "Failed to set brightness on %s", - output, - exc_info=True, - ) - - -def _beep_medium(frequency: int = 1000) -> None: - """Play a medium beep (sine tone via paplay/aplay/speaker-test).""" - _play_tone(frequency) - - -def _beep_loud(frequency: int = 1000) -> None: - """Play a loud sine tone via paplay/aplay/speaker-test.""" - _play_tone(frequency) - - -def _pactl_path() -> str | None: - """Return the absolute path to pactl, or None when not installed.""" - return shutil.which("pactl") - - -def _alarm_sink_present(pactl: str) -> bool: - """Return True when the dedicated alarm HDMI sink exists in PipeWire.""" - try: - result = subprocess.run( - [pactl, "list", "short", "sinks"], - capture_output=True, - timeout=3, - check=False, - ) - except (OSError, subprocess.TimeoutExpired): - _logger.warning("pactl list sinks failed", exc_info=True) - return False - return ALARM_AUDIO_SINK in result.stdout.decode(errors="replace") - - -def _current_default_sink(pactl: str) -> str | None: - """Return the current default sink name, or None on failure / empty.""" - try: - result = subprocess.run( - [pactl, "get-default-sink"], - capture_output=True, - timeout=3, - check=False, - ) - except (OSError, subprocess.TimeoutExpired): - _logger.warning("pactl get-default-sink failed", exc_info=True) - return None - name = result.stdout.decode(errors="replace").strip() - return name or None - - -def _activate_alarm_audio() -> str | None: - """Force the monitor's HDMI output on and route the alarm to it. - - At wake time the Bluetooth speaker is disconnected and PipeWire only has the - ``auto_null`` sink, so the alarm is silent. This forces the HDMI card - profile on, waits for its sink to appear, makes it the default sink, and - raises it to full volume - empirically the only output audible on this - machine at wake time (the G27Q monitor's built-in speaker). - - Returns: - The previous default sink name (to restore on close), or ``None`` when - the alarm audio sink could not be activated. - """ - pactl = _pactl_path() - if pactl is None: - _logger.warning("pactl not on PATH; cannot activate alarm audio") - return None - subprocess.run( - [pactl, "set-card-profile", ALARM_AUDIO_CARD, ALARM_AUDIO_PROFILE], - capture_output=True, - timeout=3, - check=False, - ) - attempts = max( - 1, - int(ALARM_AUDIO_SINK_WAIT_SECONDS / ALARM_AUDIO_SINK_POLL_SECONDS), - ) - for _ in range(attempts): - if _alarm_sink_present(pactl): - break - time.sleep(ALARM_AUDIO_SINK_POLL_SECONDS) - else: - _logger.warning( - "Alarm audio sink %s did not appear after %.0fs; alarm may be silent", - ALARM_AUDIO_SINK, - ALARM_AUDIO_SINK_WAIT_SECONDS, - ) - return None - old_default = _current_default_sink(pactl) - for cmd in ( - [pactl, "set-default-sink", ALARM_AUDIO_SINK], - [pactl, "set-sink-mute", ALARM_AUDIO_SINK, "0"], - [pactl, "set-sink-volume", ALARM_AUDIO_SINK, "100%"], - ): - subprocess.run(cmd, capture_output=True, timeout=3, check=False) - _logger.warning("Alarm audio routed to %s at 100%%", ALARM_AUDIO_SINK) - return old_default - - -def _restore_alarm_audio(old_default: str | None) -> None: - """Restore the default sink captured by :func:`_activate_alarm_audio`.""" - if old_default is None: - return - pactl = _pactl_path() - if pactl is None: - return - subprocess.run( - [pactl, "set-default-sink", old_default], - capture_output=True, - timeout=3, - check=False, - ) - - -def _warn_if_no_real_sink() -> None: - """Log a loud warning if PipeWire only has the auto_null sink.""" - pactl = _pactl_path() - if pactl is None: - _logger.warning("pactl not on PATH; cannot verify audio sinks") - return - try: - result = subprocess.run( - [pactl, "list", "short", "sinks"], - capture_output=True, - timeout=5, - check=False, - ) - except (OSError, subprocess.TimeoutExpired): - _logger.warning("pactl list sinks failed", exc_info=True) - return - sinks_text = result.stdout.decode(errors="replace").strip() - sink_names = [ - line.split("\t")[1] for line in sinks_text.splitlines() if "\t" in line - ] - real_sinks = [s for s in sink_names if s != "auto_null"] - if not real_sinks: - _logger.warning( - "ONLY auto_null PipeWire sink available — alarm will be SILENT. Sinks: %s", - sink_names or "", - ) - else: - _logger.info("Audio sinks available: %s", sink_names) diff --git a/python_pkg/wake_alarm/_challenges.py b/python_pkg/wake_alarm/_challenges.py deleted file mode 100644 index 88cfe93..0000000 --- a/python_pkg/wake_alarm/_challenges.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Dismiss-challenge types for the wake alarm. - -Provides three challenge variants: -- math: solve an arithmetic problem -- sort: type shuffled digits in ascending order -- flash: memorise a code before it is hidden -""" - -from __future__ import annotations - -import secrets - -from python_pkg.wake_alarm._constants import DISMISS_CODE_LENGTH, DISMISS_FLASH_SECONDS - -# Uppercase alphanumeric chars with visually ambiguous characters removed: -# O/0 (oh vs zero) and I/1 (capital-i vs one) are excluded so the code is -# legible at a glance, even half-asleep. -_DISMISS_CHARS: str = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" - - -class _Challenge: - """A dismiss challenge presented to the user to prove wakefulness.""" - - def __init__( - self, - *, - kind: str, - display: str, - answer: str, - hint: str, - ) -> None: - """Store challenge parameters. - - Args: - kind: Challenge type — "math", "flash", or "sort". - display: Text shown in the large code label. - answer: Expected typed answer (normalised, upper-case). - hint: Short instruction shown above the code label. - """ - self.kind: str = kind - self.display: str = display - self.answer: str = answer - self.hint: str = hint - - -def _generate_code() -> str: - """Generate a random alphanumeric dismiss code. - - Uses uppercase letters and digits only, with ambiguous characters - (O, I, 0, 1) removed so the displayed code is easy to read at a glance. - """ - return "".join(secrets.choice(_DISMISS_CHARS) for _ in range(DISMISS_CODE_LENGTH)) - - -def _make_math_challenge() -> _Challenge: - """Generate an arithmetic problem the user must solve to dismiss. - - Picks randomly from addition, subtraction, and multiplication. - The user types only the numeric answer — no copying, no autopilot. - """ - op = secrets.choice(("+", "-", "*")) - if op == "+": - a, b = 10 + secrets.randbelow(90), 10 + secrets.randbelow(90) - return _Challenge( - kind="math", - display=f"{a} + {b} = ?", - answer=str(a + b), - hint="Solve and type the answer", - ) - if op == "-": - a = 20 + secrets.randbelow(80) - b = 10 + secrets.randbelow(a - 10) - return _Challenge( - kind="math", - display=f"{a} - {b} = ?", - answer=str(a - b), - hint="Solve and type the answer", - ) - a, b = 12 + secrets.randbelow(14), 3 + secrets.randbelow(7) - return _Challenge( - kind="math", - display=f"{a} * {b} = ?", - answer=str(a * b), - hint="Solve and type the answer", - ) - - -def _make_sort_challenge() -> _Challenge: - """Generate a sort-the-digits challenge. - - Displays six shuffled single digits; the user types them ascending (no spaces). - Requires a brief cognitive effort — fast enough to be fair, slow enough to prove - you are awake. - """ - pool = list(range(1, 10)) - for i in range(len(pool) - 1, 0, -1): - j = secrets.randbelow(i + 1) - pool[i], pool[j] = pool[j], pool[i] - digits = pool[:6] - display = " ".join(str(d) for d in digits) - answer = "".join(str(d) for d in sorted(digits)) - return _Challenge( - kind="sort", - display=display, - answer=answer, - hint="Type digits sorted lowest → highest (no spaces)", - ) - - -def _make_flash_challenge() -> _Challenge: - """Generate a memorise-then-type challenge. - - Shows a code for DISMISS_FLASH_SECONDS, then hides it. - The user must type the code from memory. - """ - code = _generate_code() - return _Challenge( - kind="flash", - display=code, - answer=code, - hint=f"Memorise this code — it disappears in {DISMISS_FLASH_SECONDS}s", - ) - - -def _make_challenge() -> _Challenge: - """Pick a random challenge type and generate an instance.""" - return secrets.choice( - (_make_math_challenge, _make_flash_challenge, _make_sort_challenge), - )() diff --git a/python_pkg/wake_alarm/_constants.py b/python_pkg/wake_alarm/_constants.py deleted file mode 100644 index 7707432..0000000 --- a/python_pkg/wake_alarm/_constants.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Constants for the weekend wake alarm system.""" - -from __future__ import annotations - -from pathlib import Path - -# Days the wake alarm is active (Python weekday(): Mon=0 ... Sun=6) -# Monday, Friday, Saturday, Sunday -ALARM_DAYS: frozenset[int] = frozenset({0, 4, 5, 6}) - -# How many hours after shutdown the PC should wake -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 -FALLBACK_ALARM_HOUR: int = 13 - -# Alarm escalation phase boundaries (minutes from alarm start) -PHASE_SOFT_END: int = 5 -PHASE_MEDIUM_END: int = 15 -# After PHASE_MEDIUM_END: continuous sine tone until dismiss window closes - -# Beep intervals per phase (seconds) -SOFT_BEEP_INTERVAL: float = 10.0 -MEDIUM_BEEP_INTERVAL: float = 5.0 -LOUD_TOGGLE_INTERVAL: float = 2.0 - -# Dismiss challenge: length of the random code -DISMISS_CODE_LENGTH: int = 8 -# Number of correct code entries required to dismiss the alarm. -# Requiring more than one round forces the user to stay awake long enough -# to actually read and type multiple independent codes. -DISMISS_ROUNDS_REQUIRED: int = 2 -# Seconds the code is visible before being hidden in a flash challenge. -DISMISS_FLASH_SECONDS: int = 4 -# How often the dismiss code refreshes (seconds) -DISMISS_CODE_REFRESH_SECONDS: int = 30 - -# State file for wake alarm (HMAC-signed) -WAKE_STATE_FILE: Path = Path(__file__).resolve().parent / "wake_state.json" - -# rtcwake binary path -RTCWAKE_BIN: str = "/usr/sbin/rtcwake" - -# Alarm audio output (machine-specific, empirically verified 2026-05-25). -# At wake time the Bluetooth speaker is disconnected and PipeWire only has the -# auto_null sink, so the alarm is silent unless we activate a real output. The -# only audible always-present output on this machine is the G27Q monitor's -# built-in speaker on the NVidia GPU's HDMI audio. WirePlumber leaves the card -# profile "off", so the alarm must force the profile on and wait for the sink. -ALARM_AUDIO_CARD: str = "alsa_card.pci-0000_01_00.1" -ALARM_AUDIO_PROFILE: str = "output:hdmi-stereo" -ALARM_AUDIO_SINK: str = "alsa_output.pci-0000_01_00.1.hdmi-stereo" -# Seconds to wait for the HDMI sink to appear after forcing the profile on. -# The G27Q takes up to ~15 s to power on from a hard-off state and enumerate -# its HDMI audio; 6 s was too short when the monitor was physically off. -ALARM_AUDIO_SINK_WAIT_SECONDS: float = 20.0 -# Poll interval while waiting for the sink. -ALARM_AUDIO_SINK_POLL_SECONDS: float = 0.5 -# Seconds to pause after waking the display (xset dpms force on) before -# attempting audio setup. Gives the G27Q time to come out of power-off -# and re-enumerate its HDMI audio sink under PipeWire. -DISPLAY_WAKE_WAIT_SECONDS: float = 5.0 - -# Path to the workout log written by the companion screen_locker package. -# Dict keyed by YYYY-MM-DD date strings; presence of today's key means the -# workout was already completed and the alarm should not fire. -WORKOUT_LOG_FILE: Path = ( - Path.home() / "screen-locker" / "screen_locker" / "workout_log.json" -) - -# 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 -> -# "tapo@example.com" and password -> "your-password". -# Missing/invalid file => smart-plug control is skipped silently. -TAPO_CONFIG_FILE: Path = Path.home() / ".config" / "wake_alarm" / "tapo.json" - -# Timeout (seconds) for a single Tapo plug operation. Keep short so a -# missing/unreachable plug never delays the alarm by more than this. -TAPO_TIMEOUT_SECONDS: float = 5.0 diff --git a/python_pkg/wake_alarm/_smart_plug.py b/python_pkg/wake_alarm/_smart_plug.py deleted file mode 100644 index 271c588..0000000 --- a/python_pkg/wake_alarm/_smart_plug.py +++ /dev/null @@ -1,155 +0,0 @@ -"""TP-Link Tapo P110 smart-plug control for the wake alarm. - -Config file ``~/.config/wake_alarm/tapo.json`` (mode 0600) must contain:: - - { - "host": "192.168.x.x", - "email": "tapo-account@example.com", - "password": "tapo-account-password", - } - -If the file is missing, malformed, the ``kasa`` package is unavailable, or -the plug cannot be reached within :data:`TAPO_TIMEOUT_SECONDS`, the -operation is skipped with a WARNING log entry — the alarm must never -block on the plug. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import json -import logging -from typing import TYPE_CHECKING - -from python_pkg.wake_alarm._constants import ( - TAPO_CONFIG_FILE, - TAPO_TIMEOUT_SECONDS, -) - -if TYPE_CHECKING: - from kasa import Device - -_logger = logging.getLogger(__name__) - -# ``kasa`` is an optional runtime dependency. Import at module load time so -# we fail fast if it is missing rather than re-importing on every call. -try: - from kasa import Credentials, Discover - from kasa.exceptions import KasaException - - _KASA_AVAILABLE = True -except ImportError: - _KASA_AVAILABLE = False - _logger.warning( - "python-kasa is not installed; Tapo smart-plug control disabled", - ) - - -def _load_config() -> dict[str, str] | None: - """Return validated Tapo config from :data:`TAPO_CONFIG_FILE`, or ``None``. - - Returns: - ``None`` if the file is missing, unreadable, malformed, or missing - any of the required keys. Otherwise a dict with ``host``, ``email``, - ``password``. - """ - try: - with TAPO_CONFIG_FILE.open(encoding="utf-8") as fh: - data = json.load(fh) - except FileNotFoundError: - _logger.warning( - "Tapo config %s does not exist; smart-plug control disabled", - TAPO_CONFIG_FILE, - ) - return None - except (OSError, json.JSONDecodeError): - _logger.warning( - "Tapo config %s is unreadable or malformed; skipping plug control", - TAPO_CONFIG_FILE, - exc_info=True, - ) - return None - if not isinstance(data, dict): - _logger.warning( - "Tapo config %s is not a JSON object; skipping plug control", - TAPO_CONFIG_FILE, - ) - return None - required = ("host", "email", "password") - if not all(isinstance(data.get(k), str) and data[k] for k in required): - _logger.warning( - "Tapo config %s missing required keys %s; skipping plug control", - TAPO_CONFIG_FILE, - required, - ) - return None - return {k: data[k] for k in required} - - -async def _connect(config: dict[str, str]) -> Device | None: - """Open a connection to the configured plug, or ``None`` on failure.""" - try: - dev = await Discover.discover_single( - config["host"], - credentials=Credentials(config["email"], config["password"]), - ) - except (KasaException, OSError, asyncio.TimeoutError): - _logger.warning("Tapo plug discovery failed", exc_info=True) - return None - try: - await dev.update() - except (KasaException, OSError, asyncio.TimeoutError): - _logger.warning("Tapo plug update failed", exc_info=True) - with contextlib.suppress(KasaException, OSError): - await dev.disconnect() - return None - return dev - - -async def _set_state(*, on: bool) -> None: - """Connect to the plug and set its on/off state.""" - config = _load_config() - if config is None: - return - dev = await _connect(config) - if dev is None: - return - try: - if on: - await dev.turn_on() - else: - await dev.turn_off() - except (KasaException, OSError, asyncio.TimeoutError): - _logger.warning("Tapo plug toggle failed", exc_info=True) - finally: - with contextlib.suppress(KasaException, OSError): - await dev.disconnect() - - -def _run(*, on: bool) -> None: - """Run :func:`_set_state` with a hard timeout. Never raises.""" - if not _KASA_AVAILABLE: - _logger.warning( - "python-kasa unavailable; skipping Tapo plug %s", - "ON" if on else "OFF", - ) - return - - async def _runner() -> None: - await asyncio.wait_for(_set_state(on=on), timeout=TAPO_TIMEOUT_SECONDS) - - try: - asyncio.run(_runner()) - except (asyncio.TimeoutError, OSError, RuntimeError): - _logger.warning("Tapo plug control timed out or failed", exc_info=True) - - -def turn_on_plug() -> None: - """Turn the configured Tapo plug on. Logs a WARNING if not configured.""" - _run(on=True) - - -def turn_off_plug() -> None: - """Turn the configured Tapo plug off. Logs a WARNING if not configured.""" - _run(on=False) diff --git a/python_pkg/wake_alarm/_state.py b/python_pkg/wake_alarm/_state.py deleted file mode 100644 index 0066e90..0000000 --- a/python_pkg/wake_alarm/_state.py +++ /dev/null @@ -1,129 +0,0 @@ -"""HMAC-signed state management for the weekend wake alarm.""" - -from __future__ import annotations - -from datetime import datetime, timezone -import json -import logging - -from gatelock.log_integrity import ( - compute_entry_hmac, - verify_entry_hmac, -) - -from python_pkg.wake_alarm._constants import WAKE_STATE_FILE, WORKOUT_LOG_FILE - -_logger = logging.getLogger(__name__) - - -def _today_str() -> str: - """Return today's date as YYYY-MM-DD in UTC.""" - return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") - - -def save_wake_state( - *, - dismissed_at: str | None, - skip_workout: bool, -) -> bool: - """Write today's wake state with HMAC signature. - - Args: - dismissed_at: ISO time when alarm was dismissed, or None. - skip_workout: Whether the user earned a workout skip. - - Returns: - True if saved successfully, False otherwise. - """ - entry: dict[str, object] = { - "date": _today_str(), - "dismissed_at": dismissed_at, - "skip_workout": skip_workout, - } - 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( - "Saved wake state: dismissed=%s skip=%s", - dismissed_at, - skip_workout, - ) - return True - - -def load_wake_state() -> dict[str, object] | None: - """Load and verify today's wake state. - - 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 has_workout_skip_today() -> bool: - """Check if the user earned a workout skip for today.""" - state = load_wake_state() - if state is None: - return False - return bool(state.get("skip_workout")) - - -def was_alarm_dismissed_today() -> bool: - """Check if the alarm was already dismissed today.""" - state = load_wake_state() - if state is None: - return False - return state.get("dismissed_at") is not None - - -def was_workout_logged_today() -> bool: - """Check if the workout was already logged today via the screen locker. - - Reads the companion screen_locker workout_log.json. The file is a - dict keyed by YYYY-MM-DD date strings; presence of today's key means - the workout was completed and the alarm is no longer needed. - - Returns: - True if today's workout entry exists, False on any error or absence. - """ - if not WORKOUT_LOG_FILE.exists(): - return False - try: - with WORKOUT_LOG_FILE.open() as f: - log = json.load(f) - except (OSError, json.JSONDecodeError): - _logger.warning("Cannot read workout log file %s", WORKOUT_LOG_FILE) - return False - if not isinstance(log, dict): - return False - return _today_str() in log diff --git a/python_pkg/wake_alarm/install.sh b/python_pkg/wake_alarm/install.sh deleted file mode 100755 index fe4b3fc..0000000 --- a/python_pkg/wake_alarm/install.sh +++ /dev/null @@ -1,131 +0,0 @@ -#!/bin/bash -# Install the weekend wake alarm systemd user service and sudoers entry. -# -# Usage: bash install.sh -# -# What it does: -# 1. Copies wake-alarm.service to ~/.config/systemd/user/ -# 2. Enables and starts the service -# 3. Installs the systemd-sleep hook (restarts alarm after hibernate resume) -# 4. Adds a sudoers entry for passwordless rtcwake -# 5. Installs shutdown wrapper so "shutdown now" also hibernates on alarm nights -# 6. Installs fan-control script so alarm can max fans on wake -# 7. Installs python-kasa (AUR) so the alarm can toggle a Tapo P110 smart plug - -set -euo pipefail - -SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" -SERVICE_FILE="$SCRIPT_DIR/wake-alarm.service" -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" -FANS_SCRIPT_DST="/usr/local/bin/wake-alarm-fans.sh" -SYSTEMD_USER_DIR="$HOME/.config/systemd/user" -SLEEP_HOOK_DST="/usr/lib/systemd/system-sleep/wake-alarm.sh" -SHUTDOWN_WRAPPER_DST="/usr/local/bin/shutdown" -SUDOERS_FILE="/etc/sudoers.d/wake-alarm" -RTCWAKE_BIN="/usr/sbin/rtcwake" - -echo "=== Weekend Wake Alarm Installer ===" - -# 0. Install system dependencies -echo "[0/7] Checking system dependencies..." -if ! command -v speaker-test &>/dev/null; then - echo " Installing alsa-utils (required for speaker-test)..." - sudo pacman -S --noconfirm alsa-utils -else - echo " alsa-utils already installed" -fi - -# 1. Install systemd user service -echo "[1/7] Installing systemd user service..." -mkdir -p "$SYSTEMD_USER_DIR" -cp "$SERVICE_FILE" "$SYSTEMD_USER_DIR/wake-alarm.service" -systemctl --user daemon-reload -echo " Installed to $SYSTEMD_USER_DIR/wake-alarm.service" - -# 2. Enable service -echo "[2/7] Enabling wake-alarm.service..." -systemctl --user enable wake-alarm.service -echo " Service enabled (will start on next boot)" - -# 3. Install systemd-sleep hook (restarts alarm after hibernate resume) -echo "[3/7] Installing systemd-sleep hook..." -sudo cp "$SLEEP_HOOK_SRC" "$SLEEP_HOOK_DST" -sudo chmod 0755 "$SLEEP_HOOK_DST" -echo " Installed to $SLEEP_HOOK_DST" - -# 4. Add sudoers entry for rtcwake (requires root) -echo "[4/7] Setting up sudoers for rtcwake..." -SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $RTCWAKE_BIN" -if [[ -f "$SUDOERS_FILE" ]] && grep -qF "$SUDOERS_LINE" "$SUDOERS_FILE"; then - echo " Sudoers entry already exists" -else - echo " Adding sudoers entry (requires sudo)..." - echo "$SUDOERS_LINE" | sudo tee "$SUDOERS_FILE" > /dev/null - sudo chmod 0440 "$SUDOERS_FILE" - echo " Added: $SUDOERS_LINE" -fi - -# 5. Install shutdown wrapper (/usr/local/bin/shutdown shadows /usr/bin/shutdown) -echo "[5/7] Installing shutdown wrapper..." -sudo cp "$SHUTDOWN_WRAPPER_SRC" "$SHUTDOWN_WRAPPER_DST" -sudo chmod 0755 "$SHUTDOWN_WRAPPER_DST" -echo " Installed to $SHUTDOWN_WRAPPER_DST" -echo " 'shutdown now' will now hibernate (not poweroff) on alarm nights." - -# 6. Install fan-control script and its sudoers entry -echo "[6/7] Installing fan-control script..." -sudo cp "$FANS_SCRIPT_SRC" "$FANS_SCRIPT_DST" -sudo chmod 0755 "$FANS_SCRIPT_DST" -FANS_SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $FANS_SCRIPT_DST" -if [[ -f "$SUDOERS_FILE" ]] && grep -qF "$FANS_SUDOERS_LINE" "$SUDOERS_FILE"; then - echo " Fan sudoers entry already exists" -else - # Append to existing file (or create) - echo "$FANS_SUDOERS_LINE" | sudo tee -a "$SUDOERS_FILE" > /dev/null - sudo chmod 0440 "$SUDOERS_FILE" - echo " Added fan sudoers entry" -fi - -# 7. Install python-kasa (AUR) for TP-Link Tapo P110 smart-plug control -echo "[7/8] Installing python-kasa (AUR)..." -if python -c 'import kasa' 2>/dev/null; then - echo " python-kasa already installed" -elif command -v yay &>/dev/null; then - yay -S --noconfirm --needed python-kasa -else - echo " WARNING: yay not found; install python-kasa manually for smart-plug support" >&2 -fi -if [[ ! -f "$HOME/.config/wake_alarm/tapo.json" ]]; then - echo " NOTE: ~/.config/wake_alarm/tapo.json not found — smart-plug control is disabled." - echo " Create it (mode 0600) with keys: host, email, password." -fi - -# 8. Install ddcutil for DDC/CI monitor power control -# ddcutil lets the alarm force the G27Q on via DDC/CI even when the monitor -# was physically powered off (power button), bypassing DPMS limitations. -echo "[8/8] Installing ddcutil (DDC/CI monitor power control)..." -if command -v ddcutil &>/dev/null; then - echo " ddcutil already installed" -else - sudo pacman -S --noconfirm ddcutil - echo " ddcutil installed" -fi -# ddcutil needs access to /dev/i2c-* — add user to i2c group if it exists. -if getent group i2c &>/dev/null; then - if ! id -nG "$USER" | grep -qw i2c; then - sudo usermod -aG i2c "$USER" - echo " Added $USER to i2c group (re-login required for group to take effect)" - else - echo " $USER already in i2c group" - fi -else - echo " i2c group not found — ddcutil will run via sudo" -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 "Fans will ramp to 100% while the alarm is active, then restore automatically." -echo "To test now: python -m python_pkg.wake_alarm._alarm --demo" diff --git a/python_pkg/wake_alarm/shutdown-wrapper.sh b/python_pkg/wake_alarm/shutdown-wrapper.sh deleted file mode 100755 index 3022e5e..0000000 --- a/python_pkg/wake_alarm/shutdown-wrapper.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash -# Wrapper for /usr/bin/shutdown that redirects to rtcwake -m disk on alarm -# nights (Mon, Fri, Sat, Sun by tomorrow's day-of-week). This ensures that -# both the automated systemd timer AND manual "shutdown now" hibernate -# correctly so the PC wakes for the morning alarm. -# -# Install to /usr/local/bin/shutdown (takes priority over /usr/bin/shutdown -# because /usr/local/bin appears first in PATH). - -set -euo pipefail - -REAL_SHUTDOWN=/usr/bin/shutdown -RTCWAKE=/usr/sbin/rtcwake -WAKE_AFTER_HOURS=8 # Must match WAKE_AFTER_HOURS in python_pkg/wake_alarm/_constants.py - -# Pass through reboots and cancel commands unchanged. -for arg in "$@"; do - case "$arg" in - -r|--reboot|-c|--cancel) - exec "$REAL_SHUTDOWN" "$@" - ;; - esac -done - -# Check if tomorrow is an alarm day (Mon=1, Fri=5, Sat=6, Sun=7 in date +%u). -tomorrow_dow=$(date -d "tomorrow" +%u) -case "$tomorrow_dow" in - 1|5|6|7) - wake_epoch=$(( $(printf '%(%s)T' -1) + WAKE_AFTER_HOURS * 3600 )) - logger -t shutdown-wrapper \ - "Tomorrow is alarm day (dow=$tomorrow_dow) — hibernating, RTC wake at epoch $wake_epoch" - sudo "$RTCWAKE" -m no -t "$wake_epoch" - exec /usr/bin/systemctl hibernate - ;; - *) - exec "$REAL_SHUTDOWN" "$@" - ;; -esac diff --git a/python_pkg/wake_alarm/sleep-hook.sh b/python_pkg/wake_alarm/sleep-hook.sh deleted file mode 100755 index 1cd90d3..0000000 --- a/python_pkg/wake_alarm/sleep-hook.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash -# systemd-sleep hook: start the unified morning routine after resume. -# -# Installed to /usr/lib/systemd/system-sleep/wake-alarm.sh by install.sh. -# -# When the PC hibernates (rtcwake -m disk) and resumes the next morning, the -# user session is restored but no morning service is running. This hook starts -# morning-routine.service, which runs the wake alarm first (it owns the -# fullscreen until dismissed) and then the workout screen lock - one coherent -# flow, with the two never fighting for the screen. - -if [[ "$1" != "post" ]]; then - exit 0 -fi - -logger -t wake-alarm-hook "Woke from sleep (type=$2) - starting morning-routine.service for active sessions" - -# Start wake-alarm.service for every logged-in user that has a running session -# bus. Works with systemd >= 219. -while IFS= read -r uid; do - runtime_dir="/run/user/$uid" - [[ -d "$runtime_dir" ]] || continue - username=$(id -nu "$uid" 2>/dev/null) || continue - logger -t wake-alarm-hook "Starting morning-routine.service for user $username (uid=$uid)" - XDG_RUNTIME_DIR="$runtime_dir" \ - DBUS_SESSION_BUS_ADDRESS="unix:path=${runtime_dir}/bus" \ - runuser -u "$username" -- \ - systemctl --user start morning-routine.service 2>/dev/null \ - || logger -t wake-alarm-hook "Failed to start morning-routine.service for $username (non-fatal)" -done < <(loginctl list-sessions --no-legend 2>/dev/null | awk '{print $2}' | sort -u) diff --git a/python_pkg/wake_alarm/tests/__init__.py b/python_pkg/wake_alarm/tests/__init__.py deleted file mode 100644 index 6004834..0000000 --- a/python_pkg/wake_alarm/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the wake alarm package.""" diff --git a/python_pkg/wake_alarm/tests/test_alarm.py b/python_pkg/wake_alarm/tests/test_alarm.py deleted file mode 100644 index a63ecd3..0000000 --- a/python_pkg/wake_alarm/tests/test_alarm.py +++ /dev/null @@ -1,473 +0,0 @@ -"""Tests for _alarm.py — wake alarm daemon, UI, and beep logic.""" - -from __future__ import annotations - -import pathlib -import subprocess -import tkinter as tk -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Generator - -from python_pkg.wake_alarm._alarm import ( - _is_alarm_day, - _should_run_alarm, -) -from python_pkg.wake_alarm._audio import ( - _beep_loud, - _beep_medium, - _beep_soft, - _find_fan_hwmon, - _max_fans, - _play_on_extra_devices, - _restore_fans, - _speaker_test_path, -) -from python_pkg.wake_alarm._challenges import ( - _DISMISS_CHARS, - _generate_code, -) -from python_pkg.wake_alarm._constants import ( - DISMISS_CODE_LENGTH, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_mock_tk() -> MagicMock: - """Build a MagicMock that stands in for the tkinter module.""" - mock = MagicMock() - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - mock.Frame.return_value = MagicMock() - mock.Label.return_value = MagicMock() - mock.Entry.return_value = MagicMock() - mock.TclError = tk.TclError - mock.END = tk.END - return mock - - -@pytest.fixture(autouse=True) -def _block_real_tk() -> Generator[MagicMock]: - """Prevent any real Tk windows in tests.""" - mock = _make_mock_tk() - with ( - patch("python_pkg.wake_alarm._alarm.tk", mock), - patch( - "python_pkg.wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -@pytest.fixture -def mock_tk_module() -> Generator[MagicMock]: - """Provide explicit access to the mocked tk module.""" - mock = _make_mock_tk() - with ( - patch("python_pkg.wake_alarm._alarm.tk", mock), - patch( - "python_pkg.wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -# --------------------------------------------------------------------------- -# Unit tests for pure functions -# --------------------------------------------------------------------------- - - -class TestGenerateCode: - """Tests for _generate_code.""" - - def test_correct_length(self) -> None: - """Generated code has the configured length.""" - code = _generate_code() - assert len(code) == DISMISS_CODE_LENGTH - - def test_all_alphanumeric(self) -> None: - """Generated code uses only the unambiguous alphanumeric charset.""" - - code = _generate_code() - assert all(c in _DISMISS_CHARS for c in code) - - def test_different_codes(self) -> None: - """Two calls produce different codes (probabilistic, but safe).""" - codes = {_generate_code() for _ in range(50)} - assert len(codes) > 1 - - -class TestIsAlarmDay: - """Tests for _is_alarm_day.""" - - def test_monday_is_alarm_day(self) -> None: - """Monday (weekday=0) is an alarm day.""" - from datetime import datetime - - # Create a date that is Monday - with patch( - "python_pkg.wake_alarm._alarm.datetime", - ) as mock_dt: - mock_now = MagicMock() - mock_now.weekday.return_value = 0 # Monday - mock_dt.now.return_value = mock_now - mock_dt.side_effect = datetime - assert _is_alarm_day() is True - - def test_tuesday_is_not_alarm_day(self) -> None: - """Tuesday (weekday=1) is NOT an alarm day.""" - with patch( - "python_pkg.wake_alarm._alarm.datetime", - ) as mock_dt: - mock_now = MagicMock() - mock_now.weekday.return_value = 1 # Tuesday - mock_dt.now.return_value = mock_now - assert _is_alarm_day() is False - - def test_friday_is_alarm_day(self) -> None: - """Friday (weekday=4) is an alarm day.""" - with patch( - "python_pkg.wake_alarm._alarm.datetime", - ) as mock_dt: - mock_now = MagicMock() - mock_now.weekday.return_value = 4 # Friday - mock_dt.now.return_value = mock_now - assert _is_alarm_day() is True - - def test_saturday_is_alarm_day(self) -> None: - """Saturday (weekday=5) is an alarm day.""" - with patch( - "python_pkg.wake_alarm._alarm.datetime", - ) as mock_dt: - mock_now = MagicMock() - mock_now.weekday.return_value = 5 - mock_dt.now.return_value = mock_now - assert _is_alarm_day() is True - - def test_sunday_is_alarm_day(self) -> None: - """Sunday (weekday=6) is an alarm day.""" - with patch( - "python_pkg.wake_alarm._alarm.datetime", - ) as mock_dt: - mock_now = MagicMock() - mock_now.weekday.return_value = 6 - mock_dt.now.return_value = mock_now - assert _is_alarm_day() is True - - def test_wednesday_is_not_alarm_day(self) -> None: - """Wednesday (weekday=2) is NOT an alarm day.""" - with patch( - "python_pkg.wake_alarm._alarm.datetime", - ) as mock_dt: - mock_now = MagicMock() - mock_now.weekday.return_value = 2 - mock_dt.now.return_value = mock_now - assert _is_alarm_day() is False - - -class TestSpeakerTestPath: - """Tests for _speaker_test_path.""" - - def test_returns_path_when_found(self) -> None: - """Return full path when speaker-test is available.""" - with patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/speaker-test", - ): - assert _speaker_test_path() == "/usr/bin/speaker-test" - - def test_raises_when_not_found(self) -> None: - """Raise FileNotFoundError when speaker-test is missing.""" - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value=None, - ), - pytest.raises(FileNotFoundError, match="speaker-test not found"), - ): - _speaker_test_path() - - -class TestBeepFunctions: - """Tests for beep helper functions.""" - - def test_beep_soft_writes_bell(self) -> None: - """_beep_soft writes terminal bell character.""" - with patch("python_pkg.wake_alarm._audio.sys") as mock_sys: - mock_sys.stdout = MagicMock() - _beep_soft() - mock_sys.stdout.write.assert_called_once_with("\a") - mock_sys.stdout.flush.assert_called_once() - - def test_beep_medium_delegates_to_play_tone(self) -> None: - """_beep_medium just delegates to _play_tone.""" - with patch("python_pkg.wake_alarm._audio._play_tone") as mock_play: - _beep_medium(frequency=800) - mock_play.assert_called_once_with(800) - - def test_beep_loud_delegates_to_play_tone(self) -> None: - """_beep_loud just delegates to _play_tone.""" - with patch("python_pkg.wake_alarm._audio._play_tone") as mock_play: - _beep_loud(frequency=1200) - mock_play.assert_called_once_with(1200) - - -class TestShouldRunAlarm: - """Tests for _should_run_alarm.""" - - def test_returns_false_on_non_alarm_day(self) -> None: - """Return False when today is not an alarm day.""" - with patch( - "python_pkg.wake_alarm._alarm._is_alarm_day", - return_value=False, - ): - assert _should_run_alarm() is False - - def test_returns_false_when_already_dismissed(self) -> None: - """Return False when alarm was already dismissed today.""" - with ( - patch( - "python_pkg.wake_alarm._alarm._is_alarm_day", - return_value=True, - ), - patch( - "python_pkg.wake_alarm._alarm.was_alarm_dismissed_today", - return_value=True, - ), - ): - assert _should_run_alarm() is False - - def test_returns_true_when_alarm_day_and_not_dismissed(self) -> None: - """Return True when today is alarm day and not yet dismissed.""" - with ( - patch( - "python_pkg.wake_alarm._alarm._is_alarm_day", - return_value=True, - ), - patch( - "python_pkg.wake_alarm._alarm.was_alarm_dismissed_today", - return_value=False, - ), - patch( - "python_pkg.wake_alarm._alarm.was_workout_logged_today", - return_value=False, - ), - ): - assert _should_run_alarm() is True - - def test_returns_false_when_workout_already_logged(self) -> None: - """Return False when workout was already logged today.""" - with ( - patch( - "python_pkg.wake_alarm._alarm._is_alarm_day", - return_value=True, - ), - patch( - "python_pkg.wake_alarm._alarm.was_alarm_dismissed_today", - return_value=False, - ), - patch( - "python_pkg.wake_alarm._alarm.was_workout_logged_today", - return_value=True, - ), - ): - assert _should_run_alarm() is False - - -class TestPlayOnExtraDevices: - """Tests for _play_on_extra_devices.""" - - def test_popen_called_for_each_device(self) -> None: - """_play_on_extra_devices spawns speaker-test with PIPEWIRE_NODE set.""" - with ( - patch( - "python_pkg.wake_alarm._audio._speaker_test_path", - return_value="/usr/bin/speaker-test", - ), - patch("python_pkg.wake_alarm._audio.subprocess.Popen") as mock_popen, - ): - _play_on_extra_devices(1000) - mock_popen.assert_called_once() - args = mock_popen.call_args[0][0] - env = mock_popen.call_args.kwargs["env"] - assert "/usr/bin/speaker-test" in args - assert "-D" not in args - assert "1000" in args - assert "PIPEWIRE_NODE" in env - assert "alsa_output" in env["PIPEWIRE_NODE"] - - def test_noop_when_speaker_test_missing(self) -> None: - """_play_on_extra_devices does nothing when speaker-test is absent.""" - with ( - patch( - "python_pkg.wake_alarm._audio._speaker_test_path", - side_effect=FileNotFoundError("not found"), - ), - patch("python_pkg.wake_alarm._audio.subprocess.Popen") as mock_popen, - ): - _play_on_extra_devices(1000) - mock_popen.assert_not_called() - - def test_ignores_oserror_on_popen(self) -> None: - """_play_on_extra_devices silently ignores OSError from Popen.""" - with ( - patch( - "python_pkg.wake_alarm._audio._speaker_test_path", - return_value="/usr/bin/speaker-test", - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.Popen", - side_effect=OSError("device busy"), - ), - ): - _play_on_extra_devices(1000) # must not raise - - -class TestFindFanHwmon: - """Tests for _find_fan_hwmon.""" - - def test_returns_none_when_no_hwmon(self, monkeypatch: pytest.MonkeyPatch) -> None: - """No hwmon entries → returns None.""" - monkeypatch.setattr(pathlib.Path, "glob", lambda _s, _p: iter([])) - assert _find_fan_hwmon() is None - - def test_returns_none_for_unknown_chip( - self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Non-NCT chip name → returns None.""" - name_file = tmp_path / "name" - name_file.write_text("unknown_chip\n") - monkeypatch.setattr(pathlib.Path, "glob", lambda _s, _p: iter([name_file])) - assert _find_fan_hwmon() is None - - def test_returns_hwmon_dir_for_nct_chip( - self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """NCT chip name → returns the hwmon directory path.""" - name_file = tmp_path / "name" - name_file.write_text("nct6799\n") - monkeypatch.setattr(pathlib.Path, "glob", lambda _s, _p: iter([name_file])) - result = _find_fan_hwmon() - assert result == str(tmp_path) - - def test_skips_unreadable_name_file(self, monkeypatch: pytest.MonkeyPatch) -> None: - """OSError on read → skips and returns None.""" - bad_path = MagicMock(spec=pathlib.Path) - bad_path.read_text.side_effect = OSError("unreadable") - monkeypatch.setattr(pathlib.Path, "glob", lambda _s, _p: iter([bad_path])) - assert _find_fan_hwmon() is None - - -class TestMaxFans: - """Tests for _max_fans.""" - - def test_returns_false_when_no_hwmon(self) -> None: - """No fan controller → returns False immediately.""" - with patch("python_pkg.wake_alarm._audio._find_fan_hwmon", return_value=None): - assert _max_fans() is False - - def test_returns_false_on_script_oserror(self, tmp_path: pathlib.Path) -> None: - """OSError running fan script → returns False.""" - with ( - patch( - "python_pkg.wake_alarm._audio._find_fan_hwmon", - return_value=str(tmp_path), - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - side_effect=OSError("not found"), - ), - ): - assert _max_fans() is False - - def test_returns_false_on_script_timeout(self, tmp_path: pathlib.Path) -> None: - """TimeoutExpired running fan script → returns False.""" - with ( - patch( - "python_pkg.wake_alarm._audio._find_fan_hwmon", - return_value=str(tmp_path), - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - side_effect=subprocess.TimeoutExpired("fan", 5), - ), - ): - assert _max_fans() is False - - def test_returns_false_on_nonzero_returncode(self, tmp_path: pathlib.Path) -> None: - """Fan script exits non-zero → returns False.""" - mock_result = MagicMock() - mock_result.returncode = 1 - with ( - patch( - "python_pkg.wake_alarm._audio._find_fan_hwmon", - return_value=str(tmp_path), - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - return_value=mock_result, - ), - ): - assert _max_fans() is False - - def test_returns_true_on_success(self, tmp_path: pathlib.Path) -> None: - """Successful run → returns True (state is saved by the helper).""" - mock_result = MagicMock() - mock_result.returncode = 0 - with ( - patch( - "python_pkg.wake_alarm._audio._find_fan_hwmon", - return_value=str(tmp_path), - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - return_value=mock_result, - ), - ): - assert _max_fans() is True - - -class TestRestoreFans: - """Tests for _restore_fans.""" - - def test_noop_when_inactive(self) -> None: - """False state → subprocess.run is never called.""" - with patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run: - _restore_fans(active=False) - mock_run.assert_not_called() - - def test_calls_fan_script_restore(self) -> None: - """Active state → fan script called with restore (no args).""" - with patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run: - mock_run.return_value.returncode = 0 - _restore_fans(active=True) - mock_run.assert_called_once() - args = mock_run.call_args[0][0] - assert "restore" in args - - def test_ignores_oserror_on_restore(self) -> None: - """OSError from fan script is silently suppressed.""" - with patch( - "python_pkg.wake_alarm._audio.subprocess.run", - side_effect=OSError("no script"), - ): - _restore_fans(active=True) # must not raise - - def test_ignores_timeout_on_restore(self) -> None: - """TimeoutExpired from fan script is silently suppressed.""" - with patch( - "python_pkg.wake_alarm._audio.subprocess.run", - side_effect=subprocess.TimeoutExpired("fan", 5), - ): - _restore_fans(active=True) # must not raise diff --git a/python_pkg/wake_alarm/tests/test_alarm_audio.py b/python_pkg/wake_alarm/tests/test_alarm_audio.py deleted file mode 100644 index dc37875..0000000 --- a/python_pkg/wake_alarm/tests/test_alarm_audio.py +++ /dev/null @@ -1,420 +0,0 @@ -"""Tests for _audio.py — audio playback, fan control, and sink management.""" - -from __future__ import annotations - -import subprocess -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Iterator - import pathlib - -from python_pkg.wake_alarm._audio import ( - _beep_pcspkr, - _ensure_tone_wav, - _play_tone, - _set_max_brightness, - _try_player, -) - - -class TestSetMaxBrightness: - """Tests for _set_max_brightness.""" - - def test_noop_when_xrandr_missing(self) -> None: - """No xrandr on PATH → subprocess.run never called.""" - with ( - patch("python_pkg.wake_alarm._audio.shutil.which", return_value=None), - patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run, - ): - _set_max_brightness() - mock_run.assert_not_called() - - def test_noop_on_oserror_from_query(self) -> None: - """OSError from xrandr --query is suppressed.""" - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/xrandr", - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - side_effect=OSError("no display"), - ), - ): - _set_max_brightness() # must not raise - - def test_noop_on_timeout_from_query(self) -> None: - """TimeoutExpired from xrandr --query is suppressed.""" - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/xrandr", - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - side_effect=subprocess.TimeoutExpired("xrandr", 5), - ), - ): - _set_max_brightness() # must not raise - - def test_sets_brightness_for_connected_displays(self) -> None: - """Connected displays each get an --output --brightness call.""" - mock_query_result = MagicMock() - mock_query_result.stdout = ( - "HDMI-0 connected 2560x1440+0+0\nDP-0 connected primary\n" - ) - call_args_list: list[list[str]] = [] - - def fake_run(args: list[str], **_kwargs: object) -> MagicMock: - call_args_list.append(args) - return mock_query_result - - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/xrandr", - ), - patch("python_pkg.wake_alarm._audio.subprocess.run", side_effect=fake_run), - ): - _set_max_brightness() - - # First call is --query; subsequent calls set brightness for each output. - brightness_calls = [a for a in call_args_list if "--brightness" in a] - expected_brightness_calls = 2 - assert len(brightness_calls) == expected_brightness_calls - - def test_skips_disconnected_outputs(self) -> None: - """Disconnected outputs do NOT get a brightness call.""" - mock_result = MagicMock() - mock_result.stdout = "Screen 0: minimum 320\nHDMI-0 disconnected\n" - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/xrandr", - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - return_value=mock_result, - ) as mock_run, - ): - _set_max_brightness() - # Only the --query call, no brightness calls. - assert mock_run.call_count == 1 - - def test_warns_when_brightness_call_fails(self) -> None: - """OSError on per-output --brightness call is logged but swallowed.""" - query_result = MagicMock() - query_result.stdout = ( - "Screen 0: minimum 320\nHDMI-0 connected primary 1920x1080\n" - ) - - def _run_side_effect(args: list[str], **_kwargs: object) -> MagicMock: - if "--query" in args: - return query_result - msg = "permission denied" - raise OSError(msg) - - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/xrandr", - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - side_effect=_run_side_effect, - ), - ): - _set_max_brightness() # must not raise - - -class TestEnsureToneWav: - """Tests for _ensure_tone_wav (sine WAV generator + cache).""" - - def test_generates_and_caches(self, tmp_path: pathlib.Path) -> None: - """First call generates the WAV; second call returns the cached path.""" - from python_pkg.wake_alarm import _audio as alarm_mod - - alarm_mod._TONE_CACHE.clear() - with patch( - "python_pkg.wake_alarm._audio.tempfile.gettempdir", - return_value=str(tmp_path), - ): - path1 = _ensure_tone_wav(440) - assert path1.exists() - size = path1.stat().st_size - assert size > 0 - # Second call must hit the cache (no regeneration). - with patch("python_pkg.wake_alarm._audio.wave.open") as mock_open: - path2 = _ensure_tone_wav(440) - mock_open.assert_not_called() - assert path2 == path1 - alarm_mod._TONE_CACHE.clear() - - def test_regenerates_when_cached_file_missing( - self, - tmp_path: pathlib.Path, - ) -> None: - """If the cached file was deleted, regenerate it.""" - from python_pkg.wake_alarm._audio import _TONE_CACHE - - _TONE_CACHE.clear() - with patch( - "python_pkg.wake_alarm._audio.tempfile.gettempdir", - return_value=str(tmp_path), - ): - path1 = _ensure_tone_wav(880) - path1.unlink() - path2 = _ensure_tone_wav(880) - assert path2.exists() - _TONE_CACHE.clear() - - -class TestTryPlayer: - """Tests for _try_player.""" - - def test_returns_false_when_binary_missing( - self, - tmp_path: pathlib.Path, - ) -> None: - """Missing binary returns False without raising.""" - wav = tmp_path / "x.wav" - wav.write_bytes(b"\x00") - with patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value=None, - ): - assert _try_player("paplay", wav) is False - - def test_returns_true_on_success(self, tmp_path: pathlib.Path) -> None: - """Zero exit code returns True.""" - wav = tmp_path / "x.wav" - wav.write_bytes(b"\x00") - result = MagicMock() - result.returncode = 0 - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/paplay", - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - return_value=result, - ), - ): - assert _try_player("paplay", wav) is True - - def test_returns_false_on_nonzero_exit( - self, - tmp_path: pathlib.Path, - ) -> None: - """Non-zero exit code returns False and logs.""" - wav = tmp_path / "x.wav" - wav.write_bytes(b"\x00") - result = MagicMock() - result.returncode = 1 - result.stderr = b"boom" - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/paplay", - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - return_value=result, - ), - ): - assert _try_player("paplay", wav) is False - - def test_returns_false_on_timeout(self, tmp_path: pathlib.Path) -> None: - """TimeoutExpired returns False and logs.""" - wav = tmp_path / "x.wav" - wav.write_bytes(b"\x00") - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/paplay", - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - side_effect=subprocess.TimeoutExpired("paplay", 6), - ), - ): - assert _try_player("paplay", wav) is False - - def test_returns_false_on_oserror(self, tmp_path: pathlib.Path) -> None: - """OSError returns False and logs.""" - wav = tmp_path / "x.wav" - wav.write_bytes(b"\x00") - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/paplay", - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - side_effect=OSError("nope"), - ), - ): - assert _try_player("paplay", wav) is False - - -class TestBeepPcspkr: - """Tests for _beep_pcspkr (evdev PC speaker).""" - - def test_writes_tone_then_zero_to_device(self) -> None: - """Successful path writes start-frequency then stop event.""" - - mock_dev = MagicMock() - mock_open_ctx = MagicMock() - mock_open_ctx.__enter__.return_value = mock_dev - mock_open_ctx.__exit__.return_value = False - with ( - patch( - "python_pkg.wake_alarm._audio.Path.open", - return_value=mock_open_ctx, - ), - patch("python_pkg.wake_alarm._audio.time.sleep"), - ): - _beep_pcspkr(1000, 0.05) - # First write carries the frequency, second write carries 0 (stop). - assert mock_dev.write.call_count == 2 - - def test_oserror_is_swallowed(self) -> None: - """OSError opening the device must not raise.""" - - with patch( - "python_pkg.wake_alarm._audio.Path.open", - side_effect=OSError("no device"), - ): - _beep_pcspkr(1000, 0.05) # must not raise - - -class TestPlayTone: - """Tests for _play_tone.""" - - @pytest.fixture(autouse=True) - def _silence_pcspkr(self) -> Iterator[None]: - """Stop tests from hitting the real /dev/input PC speaker device.""" - with patch("python_pkg.wake_alarm._audio._beep_pcspkr"): - yield - - def test_paplay_success_short_circuits(self, tmp_path: pathlib.Path) -> None: - """If paplay succeeds, no further players are tried.""" - wav = tmp_path / "tone.wav" - wav.write_bytes(b"\x00") - with ( - patch( - "python_pkg.wake_alarm._audio._ensure_tone_wav", - return_value=wav, - ), - patch( - "python_pkg.wake_alarm._audio._try_player", - return_value=True, - ) as mock_try, - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - ) as mock_run, - ): - _play_tone(440) - mock_try.assert_called_once_with("paplay", wav) - mock_run.assert_not_called() - - def test_falls_back_to_aplay_then_speaker_test( - self, - tmp_path: pathlib.Path, - ) -> None: - """paplay+aplay fail → speaker-test is tried.""" - wav = tmp_path / "tone.wav" - wav.write_bytes(b"\x00") - with ( - patch( - "python_pkg.wake_alarm._audio._ensure_tone_wav", - return_value=wav, - ), - patch( - "python_pkg.wake_alarm._audio._try_player", - return_value=False, - ), - patch( - "python_pkg.wake_alarm._audio._speaker_test_path", - return_value="/usr/bin/speaker-test", - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - ) as mock_run, - ): - _play_tone(1000) - mock_run.assert_called_once() - args = mock_run.call_args[0][0] - assert "/usr/bin/speaker-test" in args - assert "1000" in args - - def test_soft_beep_when_speaker_test_missing( - self, - tmp_path: pathlib.Path, - ) -> None: - """All players fail → soft beep.""" - wav = tmp_path / "tone.wav" - wav.write_bytes(b"\x00") - with ( - patch( - "python_pkg.wake_alarm._audio._ensure_tone_wav", - return_value=wav, - ), - patch( - "python_pkg.wake_alarm._audio._try_player", - return_value=False, - ), - patch( - "python_pkg.wake_alarm._audio._speaker_test_path", - side_effect=FileNotFoundError("missing"), - ), - patch("python_pkg.wake_alarm._audio._beep_soft") as mock_soft, - ): - _play_tone(800) - mock_soft.assert_called_once() - - def test_soft_beep_when_speaker_test_times_out( - self, - tmp_path: pathlib.Path, - ) -> None: - """speaker-test TimeoutExpired → soft beep.""" - wav = tmp_path / "tone.wav" - wav.write_bytes(b"\x00") - with ( - patch( - "python_pkg.wake_alarm._audio._ensure_tone_wav", - return_value=wav, - ), - patch( - "python_pkg.wake_alarm._audio._try_player", - return_value=False, - ), - patch( - "python_pkg.wake_alarm._audio._speaker_test_path", - return_value="/usr/bin/speaker-test", - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - side_effect=subprocess.TimeoutExpired("speaker-test", 6), - ), - patch("python_pkg.wake_alarm._audio._beep_soft") as mock_soft, - ): - _play_tone(800) - mock_soft.assert_called_once() - - def test_soft_beep_when_wav_generation_fails(self) -> None: - """OSError generating WAV → soft beep.""" - with ( - patch( - "python_pkg.wake_alarm._audio._ensure_tone_wav", - side_effect=OSError("disk full"), - ), - patch("python_pkg.wake_alarm._audio._beep_soft") as mock_soft, - ): - _play_tone(440) - mock_soft.assert_called_once() diff --git a/python_pkg/wake_alarm/tests/test_alarm_challenges.py b/python_pkg/wake_alarm/tests/test_alarm_challenges.py deleted file mode 100644 index d574268..0000000 --- a/python_pkg/wake_alarm/tests/test_alarm_challenges.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Tests for _challenges.py — dismiss challenge generators.""" - -from __future__ import annotations - -from unittest.mock import patch - -from python_pkg.wake_alarm._challenges import ( - _DISMISS_CHARS, - _Challenge, - _make_challenge, - _make_flash_challenge, - _make_math_challenge, - _make_sort_challenge, -) -from python_pkg.wake_alarm._constants import DISMISS_FLASH_SECONDS - - -class TestMakeMathChallenge: - """Tests for _make_math_challenge.""" - - def test_kind_is_math(self) -> None: - """Challenge kind is always 'math'.""" - assert _make_math_challenge().kind == "math" - - def test_answer_is_correct_for_addition(self) -> None: - """Stored answer is numerically correct for addition.""" - with ( - patch("python_pkg.wake_alarm._challenges.secrets.choice", return_value="+"), - patch( - "python_pkg.wake_alarm._challenges.secrets.randbelow", - side_effect=[13, 35], # 10+13=23, 10+35=45 - ), - ): - ch = _make_math_challenge() - assert ch.display == "23 + 45 = ?" - assert ch.answer == "68" - - def test_answer_is_correct_for_subtraction(self) -> None: - """Stored answer is numerically correct for subtraction.""" - with ( - patch("python_pkg.wake_alarm._challenges.secrets.choice", return_value="-"), - patch( - "python_pkg.wake_alarm._challenges.secrets.randbelow", - side_effect=[30, 7], # 20+30=50, 10+7=17 - ), - ): - ch = _make_math_challenge() - assert ch.display == "50 - 17 = ?" - assert ch.answer == "33" - - def test_answer_is_correct_for_multiplication(self) -> None: - """Stored answer is numerically correct for multiplication.""" - with ( - patch("python_pkg.wake_alarm._challenges.secrets.choice", return_value="*"), - patch( - "python_pkg.wake_alarm._challenges.secrets.randbelow", - side_effect=[3, 4], # 12+3=15, 3+4=7 - ), - ): - ch = _make_math_challenge() - assert ch.display == "15 * 7 = ?" - assert ch.answer == "105" - - def test_answer_varies_across_calls(self) -> None: - """Multiple calls produce varied answers (probabilistic).""" - answers = {_make_math_challenge().answer for _ in range(30)} - assert len(answers) > 1 - - -class TestMakeSortChallenge: - """Tests for _make_sort_challenge.""" - - def test_kind_is_sort(self) -> None: - """Challenge kind is always 'sort'.""" - assert _make_sort_challenge().kind == "sort" - - def test_answer_is_sorted_digits(self) -> None: - """Answer equals the digits in display sorted ascending.""" - ch = _make_sort_challenge() - displayed_digits = [int(c) for c in ch.display if c.isdigit()] - expected = "".join(str(d) for d in sorted(displayed_digits)) - assert ch.answer == expected - - def test_display_contains_six_digits(self) -> None: - """Display always contains exactly six digit characters.""" - ch = _make_sort_challenge() - assert len([c for c in ch.display if c.isdigit()]) == 6 - - def test_answer_varies_across_calls(self) -> None: - """Multiple calls produce varied digit sets.""" - answers = {_make_sort_challenge().answer for _ in range(30)} - assert len(answers) > 1 - - -class TestMakeFlashChallenge: - """Tests for _make_flash_challenge.""" - - def test_kind_is_flash(self) -> None: - """Challenge kind is always 'flash'.""" - assert _make_flash_challenge().kind == "flash" - - def test_display_equals_answer(self) -> None: - """Display and answer are identical (the user must recall the full code).""" - ch = _make_flash_challenge() - assert ch.display == ch.answer - - def test_code_uses_dismiss_chars(self) -> None: - """Generated code only contains chars from _DISMISS_CHARS.""" - ch = _make_flash_challenge() - assert all(c in _DISMISS_CHARS for c in ch.answer) - - def test_hint_mentions_flash_seconds(self) -> None: - """Hint text includes the number of visible seconds.""" - ch = _make_flash_challenge() - assert str(DISMISS_FLASH_SECONDS) in ch.hint - - -class TestMakeChallenge: - """Tests for _make_challenge (the random dispatcher).""" - - def test_returns_a_challenge(self) -> None: - """Returns a _Challenge instance with all expected fields populated.""" - ch = _make_challenge() - assert isinstance(ch, _Challenge) - assert ch.kind in ("math", "flash", "sort") - assert ch.display - assert ch.answer - assert ch.hint - - def test_all_types_reachable(self) -> None: - """All three challenge types appear across many calls.""" - kinds = {_make_challenge().kind for _ in range(200)} - assert kinds == {"math", "flash", "sort"} diff --git a/python_pkg/wake_alarm/tests/test_alarm_display.py b/python_pkg/wake_alarm/tests/test_alarm_display.py deleted file mode 100644 index a71f74c..0000000 --- a/python_pkg/wake_alarm/tests/test_alarm_display.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Tests for _alarm_display.py — DDC/CI and DPMS display power helpers.""" - -from __future__ import annotations - -import subprocess -from unittest.mock import MagicMock, patch - -from python_pkg.wake_alarm._alarm_display import ( - _ddcutil_power_on, - _restore_display, - _wake_display, -) - - -class TestDdcutilPowerOn: - """Tests for _ddcutil_power_on.""" - - def test_skips_when_ddcutil_missing(self) -> None: - """_ddcutil_power_on does nothing when ddcutil is not on PATH.""" - with ( - patch( - "python_pkg.wake_alarm._alarm_display.shutil.which", - return_value=None, - ), - patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, - ): - _ddcutil_power_on() - mock_run.assert_not_called() - - def test_runs_setvcp_when_ddcutil_present(self) -> None: - """_ddcutil_power_on sends setvcp D6 01 when ddcutil is found.""" - with ( - patch( - "python_pkg.wake_alarm._alarm_display.shutil.which", - return_value="/usr/bin/ddcutil", - ), - patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, - ): - _ddcutil_power_on() - mock_run.assert_called_once() - cmd = mock_run.call_args[0][0] - assert cmd == ["/usr/bin/ddcutil", "setvcp", "D6", "01"] - - def test_logs_success_when_returncode_zero(self) -> None: - """_ddcutil_power_on logs success when setvcp returns 0.""" - with ( - patch( - "python_pkg.wake_alarm._alarm_display.shutil.which", - return_value="/usr/bin/ddcutil", - ), - patch( - "python_pkg.wake_alarm._alarm_display.subprocess.run", - return_value=MagicMock(returncode=0), - ), - ): - _ddcutil_power_on() - - def test_handles_timeout(self) -> None: - """_ddcutil_power_on does not raise on TimeoutExpired.""" - with ( - patch( - "python_pkg.wake_alarm._alarm_display.shutil.which", - return_value="/usr/bin/ddcutil", - ), - patch( - "python_pkg.wake_alarm._alarm_display.subprocess.run", - side_effect=subprocess.TimeoutExpired(cmd="ddcutil", timeout=10), - ), - ): - _ddcutil_power_on() # must not raise - - def test_handles_oserror(self) -> None: - """_ddcutil_power_on does not raise on OSError.""" - with ( - patch( - "python_pkg.wake_alarm._alarm_display.shutil.which", - return_value="/usr/bin/ddcutil", - ), - patch( - "python_pkg.wake_alarm._alarm_display.subprocess.run", - side_effect=OSError("no device"), - ), - ): - _ddcutil_power_on() # must not raise - - -class TestDisplayHelpers: - """Tests for _wake_display and _restore_display when xset is absent.""" - - def test_wake_display_skips_when_xset_missing(self) -> None: - """_wake_display skips xset commands but still attempts ddcutil.""" - with ( - patch( - "python_pkg.wake_alarm._alarm_display.shutil.which", - return_value=None, - ), - patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, - ): - _wake_display() - mock_run.assert_not_called() - - def test_wake_display_runs_ddcutil_and_xset_commands(self) -> None: - """_wake_display runs ddcutil setvcp, xset dpms force on, xset s off.""" - with ( - patch( - "python_pkg.wake_alarm._alarm_display.shutil.which", - return_value="/usr/bin/xset", - ), - patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, - ): - _wake_display() - # 1 ddcutil setvcp call + 2 xset calls - assert mock_run.call_count == 3 - call_args = [call[0][0] for call in mock_run.call_args_list] - assert ["/usr/bin/xset", "setvcp", "D6", "01"] in call_args - assert ["/usr/bin/xset", "dpms", "force", "on"] in call_args - assert ["/usr/bin/xset", "s", "off"] in call_args - - def test_restore_display_skips_when_xset_missing(self) -> None: - """_restore_display does nothing when xset is not on PATH.""" - with ( - patch( - "python_pkg.wake_alarm._alarm_display.shutil.which", - return_value=None, - ), - patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, - ): - _restore_display() - mock_run.assert_not_called() - - def test_restore_display_runs_xset_s_on_when_present(self) -> None: - """_restore_display re-enables the screensaver via xset when present.""" - with ( - patch( - "python_pkg.wake_alarm._alarm_display.shutil.which", - return_value="/usr/bin/xset", - ), - patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, - ): - _restore_display() - mock_run.assert_called_once_with( - ["/usr/bin/xset", "s", "on"], - check=False, - capture_output=True, - timeout=5, - ) diff --git a/python_pkg/wake_alarm/tests/test_alarm_part2.py b/python_pkg/wake_alarm/tests/test_alarm_part2.py deleted file mode 100644 index 81b60c8..0000000 --- a/python_pkg/wake_alarm/tests/test_alarm_part2.py +++ /dev/null @@ -1,440 +0,0 @@ -"""Tests for _alarm.py — WakeAlarm init, dismiss, run, and beep phases (part 2).""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Generator - -from python_pkg.wake_alarm._alarm import ( - WakeAlarm, - main, -) - -# --------------------------------------------------------------------------- -# Helpers (duplicated from part 1 so this file is self-contained) -# --------------------------------------------------------------------------- - - -def _make_mock_tk() -> MagicMock: - """Build a MagicMock that stands in for the tkinter module.""" - mock = MagicMock() - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - mock.Frame.return_value = MagicMock() - mock.Label.return_value = MagicMock() - mock.Entry.return_value = MagicMock() - mock.TclError = tk.TclError - mock.END = tk.END - return mock - - -@pytest.fixture(autouse=True) -def _block_real_tk() -> Generator[MagicMock]: - """Prevent any real Tk windows in tests.""" - mock = _make_mock_tk() - with ( - patch("python_pkg.wake_alarm._alarm.tk", mock), - patch( - "python_pkg.wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -@pytest.fixture(autouse=True) -def _block_extra_devices() -> Generator[MagicMock]: - """Prevent real subprocess.Popen calls for extra ALSA devices.""" - with ( - patch("python_pkg.wake_alarm._alarm._play_on_extra_devices") as mock, - patch("python_pkg.wake_alarm._alarm._max_fans", return_value=False), - patch("python_pkg.wake_alarm._alarm._restore_fans"), - patch("python_pkg.wake_alarm._alarm._set_max_brightness"), - patch("python_pkg.wake_alarm._alarm._wake_display"), - patch("python_pkg.wake_alarm._alarm._restore_display"), - patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"), - patch("python_pkg.wake_alarm._alarm._activate_alarm_audio", return_value=None), - patch("python_pkg.wake_alarm._alarm._restore_alarm_audio"), - patch("python_pkg.wake_alarm._alarm.turn_on_plug"), - patch("python_pkg.wake_alarm._alarm.turn_off_plug"), - ): - yield mock - - -@pytest.fixture -def mock_tk_module() -> Generator[MagicMock]: - """Provide explicit access to the mocked tk module.""" - mock = _make_mock_tk() - with ( - patch("python_pkg.wake_alarm._alarm.tk", mock), - patch( - "python_pkg.wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestWakeAlarmInit: - """Tests for WakeAlarm initialization.""" - - def test_demo_mode_sets_smaller_window( - self, - mock_tk_module: MagicMock, - ) -> None: - """Demo mode still hijacks the full screen — only timers differ.""" - alarm = WakeAlarm(demo_mode=True) - assert alarm.demo_mode is True - assert alarm.dismissed is False - mock_root = mock_tk_module.Tk.return_value - # LockConfig(mode="soft") never sets overrideredirect (X11 focus bug); - # fullscreen+topmost are what take over the screen now. - mock_root.overrideredirect.assert_not_called() - fs_calls = [ - c - for c in mock_root.attributes.call_args_list - if c.kwargs.get("fullscreen") is True - ] - assert fs_calls, "fullscreen attribute must be set" - alarm._stop_beep.set() # Stop beep thread - - def test_production_mode_fullscreen( - self, - mock_tk_module: MagicMock, - ) -> None: - """Production mode activates fullscreen.""" - alarm = WakeAlarm(demo_mode=False) - assert alarm.demo_mode is False - mock_root = mock_tk_module.Tk.return_value - mock_root.overrideredirect.assert_not_called() - fs_calls = [ - c - for c in mock_root.attributes.call_args_list - if c.kwargs.get("fullscreen") is True - ] - assert fs_calls, "fullscreen attribute must be set" - alarm._stop_beep.set() - - -class TestWakeAlarmDismiss: - """Tests for alarm dismiss logic.""" - - def test_correct_code_dismisses_after_all_rounds( - self, - mock_tk_module: MagicMock, - ) -> None: - """Entering the correct answer for every required round dismisses the alarm.""" - from python_pkg.wake_alarm._constants import DISMISS_ROUNDS_REQUIRED - - alarm = WakeAlarm(demo_mode=True) - mock_entry = mock_tk_module.Entry.return_value - - with patch( - "python_pkg.wake_alarm._alarm.save_wake_state", - ) as mock_save: - for _ in range(DISMISS_ROUNDS_REQUIRED): - mock_entry.get.return_value = alarm._progress.current_challenge.answer - alarm._on_submit() - - assert alarm.dismissed is True - mock_save.assert_called_once() - assert mock_save.call_args[1]["skip_workout"] is True - alarm._stop_beep.set() - - def test_first_round_correct_does_not_dismiss( - self, - mock_tk_module: MagicMock, - ) -> None: - """A single correct entry is not enough — DISMISS_ROUNDS_REQUIRED is 2+.""" - alarm = WakeAlarm(demo_mode=True) - mock_entry = mock_tk_module.Entry.return_value - mock_entry.get.return_value = alarm._progress.current_challenge.answer - - alarm._on_submit() - - assert alarm.dismissed is False - assert alarm._progress.rounds_completed == 1 - alarm._stop_beep.set() - - def test_first_round_correct_non_flash_next_no_countdown( - self, - mock_tk_module: MagicMock, - ) -> None: - """When next challenge is math, no flash countdown is started.""" - from python_pkg.wake_alarm._challenges import _Challenge - - alarm = WakeAlarm(demo_mode=True) - mock_entry = mock_tk_module.Entry.return_value - mock_entry.get.return_value = alarm._progress.current_challenge.answer - next_math = _Challenge(kind="math", display="2 + 2 = ?", answer="4", hint="x") - with patch( - "python_pkg.wake_alarm._alarm._make_challenge", return_value=next_math - ): - alarm._on_submit() - - assert alarm._progress.current_challenge.kind == "math" - assert alarm.dismissed is False - alarm._stop_beep.set() - - def test_wrong_code_does_not_dismiss( - self, - mock_tk_module: MagicMock, - ) -> None: - """Entering the wrong answer shows an error without dismissing.""" - from python_pkg.wake_alarm._alarm import _Challenge - - alarm = WakeAlarm(demo_mode=True) - # Use a pinned math challenge so the non-flash wrong-answer branch is covered. - alarm._progress.current_challenge = _Challenge( - kind="math", display="2 + 2 = ?", answer="4", hint="test" - ) - mock_entry = mock_tk_module.Entry.return_value - mock_entry.get.return_value = "99" - - alarm._on_submit() - - assert alarm.dismissed is False - alarm._stop_beep.set() - - def test_skip_window_expired_keeps_alarm_running( - self, - mock_tk_module: MagicMock, - ) -> None: - """Skip-window expiry denies the skip but does NOT stop the alarm.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - - with patch( - "python_pkg.wake_alarm._alarm.save_wake_state", - ) as mock_save: - alarm._on_skip_window_expired() - - # Alarm stays active and audible; only the skip reward is gone. - assert alarm._progress.skip_earnable is False - assert alarm._active is True - assert alarm.dismissed is False - assert not alarm._stop_beep.is_set() - mock_save.assert_not_called() - alarm._view.info_label.configure.assert_called() - alarm._stop_beep.set() - - def test_skip_window_expired_noop_if_not_active( - self, - mock_tk_module: MagicMock, - ) -> None: - """Expiry is a no-op if alarm is no longer active.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._active = False - - alarm._on_skip_window_expired() - - # skip_earnable stays at its initial True (method returned early). - assert alarm._progress.skip_earnable is True - alarm._stop_beep.set() - - def test_dismiss_after_skip_window_earns_no_skip( - self, - mock_tk_module: MagicMock, - ) -> None: - """Typing all rounds after the skip window stops the alarm without a skip.""" - from python_pkg.wake_alarm._constants import DISMISS_ROUNDS_REQUIRED - - alarm = WakeAlarm(demo_mode=True) - alarm._progress.skip_earnable = False - mock_entry = mock_tk_module.Entry.return_value - - with patch( - "python_pkg.wake_alarm._alarm.save_wake_state", - ) as mock_save: - for _ in range(DISMISS_ROUNDS_REQUIRED): - mock_entry.get.return_value = alarm._progress.current_challenge.answer - alarm._on_submit() - - assert alarm.dismissed is True - assert mock_save.call_args[1]["skip_workout"] is False - alarm._stop_beep.set() - - -class TestMain: - """Tests for the main() entry point.""" - - def test_exits_when_not_alarm_day(self) -> None: - """main() returns early when not an alarm day.""" - with ( - patch( - "python_pkg.wake_alarm._alarm._should_run_alarm", - return_value=False, - ), - patch("python_pkg.wake_alarm._alarm.sys") as mock_sys, - ): - mock_sys.argv = ["alarm"] - main() # Should just return without error - - def test_creates_alarm_when_should_run( - self, - mock_tk_module: MagicMock, - ) -> None: - """main() creates a WakeAlarm when conditions are met.""" - del mock_tk_module - with ( - patch( - "python_pkg.wake_alarm._alarm._should_run_alarm", - return_value=True, - ), - patch( - "python_pkg.wake_alarm._alarm.sys", - ) as mock_sys, - patch.object(WakeAlarm, "run") as mock_run, - patch.object(WakeAlarm, "__init__", return_value=None), - ): - mock_sys.argv = ["alarm"] - main() - mock_run.assert_called_once() - - def test_trigger_now_bypasses_gate( - self, - mock_tk_module: MagicMock, - ) -> None: - """--trigger-now bypasses _should_run_alarm.""" - del mock_tk_module - with ( - patch( - "python_pkg.wake_alarm._alarm._should_run_alarm", - return_value=False, - ) as mock_gate, - patch("python_pkg.wake_alarm._alarm.sys") as mock_sys, - patch.object(WakeAlarm, "run") as mock_run, - patch.object(WakeAlarm, "__init__", return_value=None), - ): - mock_sys.argv = ["alarm", "--trigger-now"] - main() - mock_gate.assert_not_called() - mock_run.assert_called_once() - - -class TestCodeRefreshAndTimer: - """Tests for code refresh and timer update methods.""" - - def test_code_refresh_changes_challenge( - self, - mock_tk_module: MagicMock, - ) -> None: - """Code refresh generates a new challenge each call.""" - alarm = WakeAlarm(demo_mode=True) - displays = set() - for _ in range(50): - alarm._schedule_code_refresh() - displays.add(alarm._progress.current_challenge.display) - assert len(displays) > 1 - alarm._stop_beep.set() - - def test_code_refresh_noop_when_not_active( - self, - mock_tk_module: MagicMock, - ) -> None: - """Code refresh is a no-op when alarm is no longer active.""" - alarm = WakeAlarm(demo_mode=True) - alarm._active = False - old_challenge = alarm._progress.current_challenge - alarm._schedule_code_refresh() - assert alarm._progress.current_challenge is old_challenge - alarm._stop_beep.set() - - def test_update_timer_noop_when_not_active( - self, - mock_tk_module: MagicMock, - ) -> None: - """Timer update is a no-op when alarm is inactive.""" - alarm = WakeAlarm(demo_mode=True) - alarm._active = False - alarm._update_timer() # Should not raise - alarm._stop_beep.set() - - -class TestBeepLoop: - """Tests for the beep loop thread.""" - - def test_beep_loop_stops_on_event( - self, - mock_tk_module: MagicMock, - ) -> None: - """Beep loop exits when stop event is set.""" - alarm = WakeAlarm(demo_mode=True) - alarm._stop_beep.set() - # Loop should exit immediately - with patch( - "python_pkg.wake_alarm._alarm._beep_soft", - ): - alarm._beep_loop() - alarm._stop_beep.set() - - -class TestScreenFlash: - """Tests for _start_screen_flash and _flash_step.""" - - def test_flash_step_shows_dark_on_flash_off( - self, - mock_tk_module: MagicMock, - ) -> None: - """When _flash_on=False, the background is set to dark colour.""" - alarm = WakeAlarm(demo_mode=True) - mock_root = mock_tk_module.Tk.return_value - mock_root.configure.reset_mock() - mock_root.after.reset_mock() - - alarm._progress.flash_on = False - alarm._flash_step() - - mock_root.configure.assert_called_once_with(bg="#1a1a1a") - assert alarm._progress.flash_on is True - mock_root.after.assert_called_with(750, alarm._flash_step) - alarm._stop_beep.set() - - def test_flash_step_shows_red_on_flash_on( - self, - mock_tk_module: MagicMock, - ) -> None: - """When _flash_on=True, the background is set to red.""" - alarm = WakeAlarm(demo_mode=True) - mock_root = mock_tk_module.Tk.return_value - mock_root.configure.reset_mock() - mock_root.after.reset_mock() - - alarm._progress.flash_on = True - alarm._flash_step() - - mock_root.configure.assert_called_once_with(bg="#ff0000") - assert alarm._progress.flash_on is False - mock_root.after.assert_called_with(750, alarm._flash_step) - alarm._stop_beep.set() - - def test_flash_step_stops_when_inactive( - self, - mock_tk_module: MagicMock, - ) -> None: - """When alarm is no longer active, _flash_step returns without scheduling.""" - alarm = WakeAlarm(demo_mode=True) - mock_root = mock_tk_module.Tk.return_value - alarm._active = False - mock_root.configure.reset_mock() - mock_root.after.reset_mock() - - alarm._flash_step() - - mock_root.configure.assert_not_called() - mock_root.after.assert_not_called() - alarm._stop_beep.set() diff --git a/python_pkg/wake_alarm/tests/test_alarm_part3.py b/python_pkg/wake_alarm/tests/test_alarm_part3.py deleted file mode 100644 index f2772f4..0000000 --- a/python_pkg/wake_alarm/tests/test_alarm_part3.py +++ /dev/null @@ -1,360 +0,0 @@ -"""Tests for WakeAlarm — beep loop phases, run, update timer, and flash challenge.""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Generator - -from python_pkg.wake_alarm._alarm import ( - WakeAlarm, -) -from python_pkg.wake_alarm._constants import ( - PHASE_MEDIUM_END, - PHASE_SOFT_END, -) - - -def _make_mock_tk() -> MagicMock: - """Build a MagicMock that stands in for the tkinter module.""" - mock = MagicMock() - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - mock.Frame.return_value = MagicMock() - mock.Label.return_value = MagicMock() - mock.Entry.return_value = MagicMock() - mock.TclError = tk.TclError - mock.END = tk.END - return mock - - -@pytest.fixture(autouse=True) -def _block_real_tk() -> Generator[MagicMock]: - """Prevent any real Tk windows in tests.""" - mock = _make_mock_tk() - with ( - patch("python_pkg.wake_alarm._alarm.tk", mock), - patch( - "python_pkg.wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -@pytest.fixture(autouse=True) -def _block_extra_devices() -> Generator[MagicMock]: - """Prevent real subprocess calls for extra ALSA devices and hardware.""" - with ( - patch("python_pkg.wake_alarm._alarm._play_on_extra_devices") as mock, - patch("python_pkg.wake_alarm._alarm._max_fans", return_value=False), - patch("python_pkg.wake_alarm._alarm._restore_fans"), - patch("python_pkg.wake_alarm._alarm._set_max_brightness"), - patch("python_pkg.wake_alarm._alarm._wake_display"), - patch("python_pkg.wake_alarm._alarm._restore_display"), - patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"), - patch("python_pkg.wake_alarm._alarm._activate_alarm_audio", return_value=None), - patch("python_pkg.wake_alarm._alarm._restore_alarm_audio"), - patch("python_pkg.wake_alarm._alarm.turn_on_plug"), - patch("python_pkg.wake_alarm._alarm.turn_off_plug"), - ): - yield mock - - -@pytest.fixture -def mock_tk_module() -> Generator[MagicMock]: - """Provide explicit access to the mocked tk module.""" - mock = _make_mock_tk() - with ( - patch("python_pkg.wake_alarm._alarm.tk", mock), - patch( - "python_pkg.wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -class TestBeepLoopPhases: - """Tests for different beep loop escalation phases.""" - - def test_medium_phase( - self, - mock_tk_module: MagicMock, - ) -> None: - """Beep loop enters medium phase after PHASE_SOFT_END minutes.""" - alarm = WakeAlarm(demo_mode=True) - # Set alarm start to make elapsed > PHASE_SOFT_END minutes - import time as time_mod - - alarm._alarm_start = time_mod.monotonic() - (PHASE_SOFT_END + 1) * 60 - - call_count = 0 - - def stop_after_one(*_args: object, **_kwargs: object) -> None: - nonlocal call_count - call_count += 1 - if call_count >= 1: - alarm._stop_beep.set() - - with ( - patch( - "python_pkg.wake_alarm._alarm._beep_medium", - side_effect=stop_after_one, - ) as mock_beep, - ): - alarm._beep_loop() - - mock_beep.assert_called() - alarm._stop_beep.set() - - def test_loud_phase( - self, - mock_tk_module: MagicMock, - ) -> None: - """Beep loop enters loud phase after PHASE_MEDIUM_END minutes.""" - alarm = WakeAlarm(demo_mode=True) - import time as time_mod - - alarm._alarm_start = time_mod.monotonic() - (PHASE_MEDIUM_END + 1) * 60 - - call_count = 0 - - def stop_after_one(*_args: object, **_kwargs: object) -> None: - nonlocal call_count - call_count += 1 - if call_count >= 1: - alarm._stop_beep.set() - - with ( - patch( - "python_pkg.wake_alarm._alarm._beep_loud", - side_effect=stop_after_one, - ) as mock_beep, - ): - alarm._beep_loop() - - mock_beep.assert_called() - alarm._stop_beep.set() - - -class TestRunMethod: - """Tests for the run() method.""" - - def test_run_delegates_to_lock( - self, - mock_tk_module: MagicMock, - ) -> None: - """run() hands off to the owned LockWindow. - - Asserts delegation rather than calling the real LockWindow.run(), - which installs real SIGTERM/SIGINT handlers in the test process. - """ - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - with patch.object(alarm._lock, "run") as mock_run: - alarm.run() - mock_run.assert_called_once_with() - alarm._stop_beep.set() - - -class TestUpdateTimerActive: - """Tests for timer update when alarm is active.""" - - def test_update_timer_shows_skip_window( - self, - mock_tk_module: MagicMock, - ) -> None: - """While the skip is earnable, the timer shows the skip-window count.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._update_timer() - text = alarm._view.timer_label.configure.call_args[1]["text"] - assert text.startswith("Skip window:") - alarm._stop_beep.set() - - def test_update_timer_shows_prompt_after_window( - self, - mock_tk_module: MagicMock, - ) -> None: - """After the window the timer shows the silence prompt and keeps going.""" - import time as time_mod - - alarm = WakeAlarm(demo_mode=True) - # Far in the past so remaining == 0 -> the else branch. - alarm._alarm_start = time_mod.monotonic() - 60 * 60 - alarm.root.after.reset_mock() - alarm._update_timer() - text = alarm._view.timer_label.configure.call_args[1]["text"] - assert "type the code" in text - # The alarm keeps nagging: it always reschedules while active. - alarm.root.after.assert_called_once() - alarm._stop_beep.set() - - def test_update_timer_noop_when_not_active( - self, - mock_tk_module: MagicMock, - ) -> None: - """Timer update is a no-op once the alarm is no longer active.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._active = False - alarm._view.timer_label.configure.reset_mock() - alarm._update_timer() - alarm._view.timer_label.configure.assert_not_called() - alarm._stop_beep.set() - - -class TestFlashChallenge: - """Tests for flash challenge countdown behaviour inside WakeAlarm.""" - - def test_flash_tick_counts_down_and_hides( - self, - mock_tk_module: MagicMock, - ) -> None: - """_flash_tick counts down per second and hides the code at zero.""" - from python_pkg.wake_alarm._alarm import _Challenge - - alarm = WakeAlarm(demo_mode=True) - alarm._progress.current_challenge = _Challenge( - kind="flash", - display="ABCDEFGH", - answer="ABCDEFGH", - hint="Memorise", - ) - alarm._progress.flash_remaining = 2 - alarm._view.status_label.configure.reset_mock() - - alarm._flash_tick() - assert alarm._progress.flash_remaining == 1 - alarm._view.status_label.configure.assert_called() - - alarm._flash_tick() - assert alarm._progress.flash_remaining == 0 - - # Final tick hides the code. - alarm._flash_tick() - # _code_label and _status_label share the same mock; inspect all calls. - all_texts = [ - c.kwargs.get("text", "") - for c in alarm._view.code_label.configure.call_args_list - ] - assert any("?" in t for t in all_texts) - alarm._stop_beep.set() - - def test_flash_tick_noop_when_inactive( - self, - mock_tk_module: MagicMock, - ) -> None: - """_flash_tick returns immediately when the alarm is no longer active.""" - alarm = WakeAlarm(demo_mode=True) - alarm._active = False - alarm._progress.flash_remaining = 3 - alarm._view.status_label.configure.reset_mock() - - alarm._flash_tick() - - alarm._view.status_label.configure.assert_not_called() - alarm._stop_beep.set() - - def test_wrong_flash_answer_reshows_code( - self, - mock_tk_module: MagicMock, - ) -> None: - """Wrong flash answer restores the code and restarts the countdown.""" - from python_pkg.wake_alarm._alarm import _Challenge - - alarm = WakeAlarm(demo_mode=True) - alarm._progress.current_challenge = _Challenge( - kind="flash", - display="TESTCODE", - answer="TESTCODE", - hint="Memorise", - ) - mock_entry = mock_tk_module.Entry.return_value - mock_entry.get.return_value = "WRONGCODE" - alarm._view.code_label.configure.reset_mock() - - alarm._on_submit() - - assert alarm.dismissed is False - # Code label should be reconfigured (code shown again + countdown restarted). - alarm._view.code_label.configure.assert_called() - alarm._stop_beep.set() - - def test_next_round_flash_starts_countdown( - self, - mock_tk_module: MagicMock, - ) -> None: - """When the next-round challenge is flash, the countdown starts immediately.""" - from python_pkg.wake_alarm._alarm import _Challenge - - alarm = WakeAlarm(demo_mode=True) - alarm._progress.current_challenge = _Challenge( - kind="math", display="2 + 2 = ?", answer="4", hint="test" - ) - next_flash = _Challenge( - kind="flash", display="ABCDEFGH", answer="ABCDEFGH", hint="Memorise" - ) - mock_entry = mock_tk_module.Entry.return_value - mock_entry.get.return_value = "4" - - with patch( - "python_pkg.wake_alarm._alarm._make_challenge", return_value=next_flash - ): - alarm._on_submit() - - assert alarm._progress.current_challenge.kind == "flash" - assert alarm.dismissed is False - alarm._stop_beep.set() - - -class TestDismissWithoutSkip: - """Tests for alarm dismiss without earning skip.""" - - def test_dismiss_without_skip_shows_no_skip_message( - self, - mock_tk_module: MagicMock, - ) -> None: - """Dismissing with earned_skip=False shows appropriate message.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - mock_widget = MagicMock() - alarm._view.container.winfo_children.return_value = [mock_widget] - - with patch( - "python_pkg.wake_alarm._alarm.save_wake_state", - ) as mock_save: - alarm._dismiss_alarm(earned_skip=False) - - assert alarm.dismissed is True - mock_save.assert_called_once() - assert mock_save.call_args[1]["skip_workout"] is False - mock_widget.destroy.assert_called_once() - alarm._stop_beep.set() - - -class TestSkipWindowExpiredMessage: - """Tests for the on-screen message when the skip window expires.""" - - def test_expired_updates_status_label( - self, - mock_tk_module: MagicMock, - ) -> None: - """Expiry updates the status label instead of closing the alarm.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - - alarm._on_skip_window_expired() - - alarm._view.status_label.configure.assert_called_with( - text="No workout skip today.", - ) - alarm._stop_beep.set() diff --git a/python_pkg/wake_alarm/tests/test_alarm_part4.py b/python_pkg/wake_alarm/tests/test_alarm_part4.py deleted file mode 100644 index 31e52a5..0000000 --- a/python_pkg/wake_alarm/tests/test_alarm_part4.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Tests for WakeAlarm's gatelock hooks: on_focus_ready, on_callback_error, on_close.""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Generator - -from python_pkg.wake_alarm._alarm import WakeAlarm - -# --------------------------------------------------------------------------- -# Helpers (duplicated from part 1 so this file is self-contained) -# --------------------------------------------------------------------------- - - -def _make_mock_tk() -> MagicMock: - """Build a MagicMock that stands in for the tkinter module.""" - mock = MagicMock() - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - mock.Frame.return_value = MagicMock() - mock.Label.return_value = MagicMock() - mock.Entry.return_value = MagicMock() - mock.TclError = tk.TclError - mock.END = tk.END - return mock - - -@pytest.fixture(autouse=True) -def _block_real_tk() -> Generator[MagicMock]: - """Prevent any real Tk windows in tests.""" - mock = _make_mock_tk() - with ( - patch("python_pkg.wake_alarm._alarm.tk", mock), - patch( - "python_pkg.wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -@pytest.fixture(autouse=True) -def _block_extra_devices() -> Generator[MagicMock]: - """Prevent real subprocess.Popen calls for extra ALSA devices.""" - with ( - patch("python_pkg.wake_alarm._alarm._play_on_extra_devices") as mock, - patch("python_pkg.wake_alarm._alarm._max_fans", return_value=False), - patch("python_pkg.wake_alarm._alarm._restore_fans"), - patch("python_pkg.wake_alarm._alarm._set_max_brightness"), - patch("python_pkg.wake_alarm._alarm._wake_display"), - patch("python_pkg.wake_alarm._alarm._restore_display"), - patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"), - patch("python_pkg.wake_alarm._alarm._activate_alarm_audio", return_value=None), - patch("python_pkg.wake_alarm._alarm._restore_alarm_audio"), - patch("python_pkg.wake_alarm._alarm.turn_on_plug"), - patch("python_pkg.wake_alarm._alarm.turn_off_plug"), - ): - yield mock - - -@pytest.fixture -def mock_tk_module() -> Generator[MagicMock]: - """Provide explicit access to the mocked tk module.""" - mock = _make_mock_tk() - with ( - patch("python_pkg.wake_alarm._alarm.tk", mock), - patch( - "python_pkg.wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -class TestGatelockHooks: - """Tests for the LockWindowHooks callbacks (on_focus_ready/on_callback_error).""" - - def test_on_focus_ready_focuses_entry( - self, - mock_tk_module: MagicMock, - ) -> None: - """on_focus_ready forces focus onto the dismiss-code entry.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._view.entry.focus_force.reset_mock() - alarm.on_focus_ready() - alarm._view.entry.focus_force.assert_called_once() - alarm._stop_beep.set() - - def test_on_callback_error_surfaces_and_refocuses( - self, - mock_tk_module: MagicMock, - ) -> None: - """on_callback_error shows a message and refocuses the entry.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._view.entry.focus_force.reset_mock() - alarm.on_callback_error() - alarm._view.status_label.configure.assert_called_with( - text="Something went wrong — try again.", - ) - alarm._view.entry.focus_force.assert_called_once() - alarm._stop_beep.set() - - -class TestClose: - """Tests for the alarm's gatelock close path (LockWindow.close/on_close).""" - - def test_lock_close_stops_beep_and_destroys( - self, - mock_tk_module: MagicMock, - ) -> None: - """LockWindow.close() runs on_close (stop event) and destroys root.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._lock.close() - assert alarm._stop_beep.is_set() - alarm.root.destroy.assert_called() - - def test_on_close_restores_fans( - self, - mock_tk_module: MagicMock, - ) -> None: - """on_close calls _restore_fans with the saved fan state.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._hardware.fan_state = True - with patch("python_pkg.wake_alarm._alarm._restore_fans") as mock_restore: - alarm.on_close() - mock_restore.assert_called_once_with(active=True) - alarm._stop_beep.set() - - def test_on_close_restores_audio( - self, - mock_tk_module: MagicMock, - ) -> None: - """on_close restores the default sink captured at activation.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._hardware.audio_restore = "jbl_sink" - with patch( - "python_pkg.wake_alarm._alarm._restore_alarm_audio", - ) as mock_restore: - alarm.on_close() - mock_restore.assert_called_once_with("jbl_sink") - alarm._stop_beep.set() diff --git a/python_pkg/wake_alarm/tests/test_alarm_sinks.py b/python_pkg/wake_alarm/tests/test_alarm_sinks.py deleted file mode 100644 index 0efb964..0000000 --- a/python_pkg/wake_alarm/tests/test_alarm_sinks.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Tests for sink management and parse_args in wake alarm.""" - -from __future__ import annotations - -import subprocess -from unittest.mock import MagicMock, patch - -from python_pkg.wake_alarm._alarm import _parse_args -from python_pkg.wake_alarm._audio import ( - _activate_alarm_audio, - _alarm_sink_present, - _current_default_sink, - _restore_alarm_audio, - _warn_if_no_real_sink, -) - - -class TestWarnIfNoRealSink: - """Tests for _warn_if_no_real_sink.""" - - def test_logs_when_pactl_missing(self) -> None: - """No pactl on PATH → warns and returns.""" - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value=None, - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - ) as mock_run, - ): - _warn_if_no_real_sink() - mock_run.assert_not_called() - - def test_warns_when_only_auto_null(self) -> None: - """Only auto_null sink → warning is emitted.""" - result = MagicMock() - result.stdout = b"4319\tauto_null\tPipeWire\tfloat32le 2ch 48000Hz\tIDLE\n" - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/pactl", - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - return_value=result, - ), - patch("python_pkg.wake_alarm._audio._logger") as mock_log, - ): - _warn_if_no_real_sink() - mock_log.warning.assert_called() - - def test_info_when_real_sink_present(self) -> None: - """A non-auto_null sink → info log, no warning.""" - result = MagicMock() - result.stdout = b"1\talsa_output.pci-0000_01_00.1.hdmi-stereo\tPipeWire\t-\t-\n" - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/pactl", - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - return_value=result, - ), - patch("python_pkg.wake_alarm._audio._logger") as mock_log, - ): - _warn_if_no_real_sink() - mock_log.info.assert_called() - mock_log.warning.assert_not_called() - - def test_handles_pactl_failure(self) -> None: - """OSError/TimeoutExpired running pactl → warning, no raise.""" - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/pactl", - ), - patch( - "python_pkg.wake_alarm._audio.subprocess.run", - side_effect=subprocess.TimeoutExpired("pactl", 5), - ), - ): - _warn_if_no_real_sink() # must not raise - - -class TestAlarmSinkPresent: - """Tests for _alarm_sink_present.""" - - def test_true_when_sink_listed(self) -> None: - """Returns True when the alarm sink name appears in pactl output.""" - from python_pkg.wake_alarm._constants import ALARM_AUDIO_SINK - - proc = MagicMock(stdout=ALARM_AUDIO_SINK.encode() + b"\tPipeWire\n") - with patch( - "python_pkg.wake_alarm._audio.subprocess.run", - return_value=proc, - ): - assert _alarm_sink_present("/usr/bin/pactl") is True - - def test_false_when_sink_absent(self) -> None: - """Returns False when the alarm sink is not in pactl output.""" - proc = MagicMock(stdout=b"auto_null\tPipeWire\n") - with patch( - "python_pkg.wake_alarm._audio.subprocess.run", - return_value=proc, - ): - assert _alarm_sink_present("/usr/bin/pactl") is False - - def test_false_on_subprocess_error(self) -> None: - """OSError while listing sinks → False, no raise.""" - with patch( - "python_pkg.wake_alarm._audio.subprocess.run", - side_effect=OSError("boom"), - ): - assert _alarm_sink_present("/usr/bin/pactl") is False - - -class TestCurrentDefaultSink: - """Tests for _current_default_sink.""" - - def test_returns_sink_name(self) -> None: - """Returns the trimmed default sink name.""" - proc = MagicMock(stdout=b"jbl_sink\n") - with patch( - "python_pkg.wake_alarm._audio.subprocess.run", - return_value=proc, - ): - assert _current_default_sink("/usr/bin/pactl") == "jbl_sink" - - def test_returns_none_when_empty(self) -> None: - """Empty output → None.""" - proc = MagicMock(stdout=b"\n") - with patch( - "python_pkg.wake_alarm._audio.subprocess.run", - return_value=proc, - ): - assert _current_default_sink("/usr/bin/pactl") is None - - def test_returns_none_on_error(self) -> None: - """TimeoutExpired → None, no raise.""" - with patch( - "python_pkg.wake_alarm._audio.subprocess.run", - side_effect=subprocess.TimeoutExpired("pactl", 3), - ): - assert _current_default_sink("/usr/bin/pactl") is None - - -class TestActivateAlarmAudio: - """Tests for _activate_alarm_audio.""" - - def test_returns_none_when_pactl_missing(self) -> None: - """No pactl on PATH → returns None without touching audio.""" - with ( - patch("python_pkg.wake_alarm._audio.shutil.which", return_value=None), - patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run, - ): - assert _activate_alarm_audio() is None - mock_run.assert_not_called() - - def test_activates_and_returns_old_default(self) -> None: - """Sink present → routes audio there and returns prior default sink.""" - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/pactl", - ), - patch( - "python_pkg.wake_alarm._audio._alarm_sink_present", - return_value=True, - ), - patch( - "python_pkg.wake_alarm._audio._current_default_sink", - return_value="jbl_sink", - ), - patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run, - ): - result = _activate_alarm_audio() - assert result == "jbl_sink" - cmds = [call.args[0] for call in mock_run.call_args_list] - from python_pkg.wake_alarm._constants import ( - ALARM_AUDIO_CARD, - ALARM_AUDIO_PROFILE, - ALARM_AUDIO_SINK, - ) - - assert [ - "/usr/bin/pactl", - "set-card-profile", - ALARM_AUDIO_CARD, - ALARM_AUDIO_PROFILE, - ] in cmds - assert ["/usr/bin/pactl", "set-default-sink", ALARM_AUDIO_SINK] in cmds - - def test_returns_none_when_sink_never_appears(self) -> None: - """Sink never shows up → returns None after polling (no raise).""" - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/pactl", - ), - patch( - "python_pkg.wake_alarm._audio._alarm_sink_present", - return_value=False, - ), - patch("python_pkg.wake_alarm._audio.time.sleep") as mock_sleep, - patch("python_pkg.wake_alarm._audio.subprocess.run"), - ): - assert _activate_alarm_audio() is None - mock_sleep.assert_called() - - def test_waits_then_succeeds(self) -> None: - """Sink absent then present → sleeps once, then routes audio.""" - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/pactl", - ), - patch( - "python_pkg.wake_alarm._audio._alarm_sink_present", - side_effect=[False, True], - ), - patch( - "python_pkg.wake_alarm._audio._current_default_sink", - return_value="old", - ), - patch("python_pkg.wake_alarm._audio.time.sleep") as mock_sleep, - patch("python_pkg.wake_alarm._audio.subprocess.run"), - ): - assert _activate_alarm_audio() == "old" - mock_sleep.assert_called_once() - - -class TestRestoreAlarmAudio: - """Tests for _restore_alarm_audio.""" - - def test_none_is_noop(self) -> None: - """None default → does nothing, no pactl lookup.""" - with patch("python_pkg.wake_alarm._audio.shutil.which") as mock_which: - _restore_alarm_audio(None) - mock_which.assert_not_called() - - def test_no_pactl_returns_silently(self) -> None: - """Default present but pactl missing → no raise, no run.""" - with ( - patch("python_pkg.wake_alarm._audio.shutil.which", return_value=None), - patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run, - ): - _restore_alarm_audio("jbl_sink") - mock_run.assert_not_called() - - def test_restores_default_sink(self) -> None: - """Calls set-default-sink with the captured prior default.""" - with ( - patch( - "python_pkg.wake_alarm._audio.shutil.which", - return_value="/usr/bin/pactl", - ), - patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run, - ): - _restore_alarm_audio("jbl_sink") - cmds = [call.args[0] for call in mock_run.call_args_list] - assert ["/usr/bin/pactl", "set-default-sink", "jbl_sink"] in cmds - - -class TestParseArgs: - """Tests for _parse_args.""" - - def test_default_flags_are_false(self) -> None: - """No CLI args means every flag is False.""" - ns = _parse_args([]) - assert ns.demo is False - assert ns.trigger_now is False - assert ns.production is False - - def test_flags_parse(self) -> None: - """Each flag flips to True when passed.""" - ns = _parse_args(["--production", "--demo", "--trigger-now"]) - assert ns.production is True - assert ns.demo is True - assert ns.trigger_now is True diff --git a/python_pkg/wake_alarm/tests/test_smart_plug.py b/python_pkg/wake_alarm/tests/test_smart_plug.py deleted file mode 100644 index c7f85fe..0000000 --- a/python_pkg/wake_alarm/tests/test_smart_plug.py +++ /dev/null @@ -1,351 +0,0 @@ -"""Tests for _smart_plug.py — Tapo P110 control with config + asyncio.""" - -from __future__ import annotations - -import asyncio -import json -from typing import TYPE_CHECKING -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from python_pkg.wake_alarm import _smart_plug -from python_pkg.wake_alarm._smart_plug import ( - _connect, - _load_config, - _run, - _set_state, - turn_off_plug, - turn_on_plug, -) - -if TYPE_CHECKING: - from collections.abc import Generator - from pathlib import Path - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_config_file(tmp_path: Path, contents: object) -> Path: - """Write ``contents`` (encoded as JSON unless str) to a config file.""" - path = tmp_path / "tapo.json" - if isinstance(contents, str): - path.write_text(contents, encoding="utf-8") - else: - path.write_text(json.dumps(contents), encoding="utf-8") - return path - - -@pytest.fixture -def _kasa_available() -> Generator[None]: - """Force _smart_plug to treat ``kasa`` as importable for the test.""" - with patch.object(_smart_plug, "_KASA_AVAILABLE", new=True): - yield - - -# --------------------------------------------------------------------------- -# _load_config -# --------------------------------------------------------------------------- - - -class TestLoadConfig: - """Tests for _load_config().""" - - def test_returns_none_when_file_missing(self, tmp_path: Path) -> None: - """Missing config file returns None.""" - with patch.object(_smart_plug, "TAPO_CONFIG_FILE", tmp_path / "missing.json"): - assert _load_config() is None - - def test_returns_none_for_invalid_json(self, tmp_path: Path) -> None: - """Malformed JSON returns None.""" - path = _make_config_file(tmp_path, "{not valid json") - with patch.object(_smart_plug, "TAPO_CONFIG_FILE", path): - assert _load_config() is None - - def test_returns_none_when_top_level_not_dict(self, tmp_path: Path) -> None: - """A JSON list at top level returns None.""" - path = _make_config_file(tmp_path, ["host", "email", "password"]) - with patch.object(_smart_plug, "TAPO_CONFIG_FILE", path): - assert _load_config() is None - - def test_returns_none_when_key_missing(self, tmp_path: Path) -> None: - """Missing required key returns None.""" - path = _make_config_file(tmp_path, {"host": "1.2.3.4", "email": "x"}) - with patch.object(_smart_plug, "TAPO_CONFIG_FILE", path): - assert _load_config() is None - - def test_returns_none_when_value_empty(self, tmp_path: Path) -> None: - """Empty-string value returns None.""" - path = _make_config_file( - tmp_path, {"host": "1.2.3.4", "email": "", "password": "p"} - ) - with patch.object(_smart_plug, "TAPO_CONFIG_FILE", path): - assert _load_config() is None - - def test_returns_none_when_value_not_string(self, tmp_path: Path) -> None: - """Non-string value returns None.""" - path = _make_config_file( - tmp_path, {"host": 1234, "email": "e", "password": "p"} - ) - with patch.object(_smart_plug, "TAPO_CONFIG_FILE", path): - assert _load_config() is None - - def test_returns_validated_dict(self, tmp_path: Path) -> None: - """Valid config returns a normalized dict with only required keys.""" - path = _make_config_file( - tmp_path, - { - "host": "192.168.1.50", - "email": "user@example.com", - "password": "secret", - "extra": "ignored", - }, - ) - with patch.object(_smart_plug, "TAPO_CONFIG_FILE", path): - assert _load_config() == { - "host": "192.168.1.50", - "email": "user@example.com", - "password": "secret", - } - - -# --------------------------------------------------------------------------- -# _connect -# --------------------------------------------------------------------------- - - -class TestConnect: - """Tests for _connect().""" - - def test_returns_device_on_success(self) -> None: - """Successful discover + update returns the device.""" - dev = MagicMock() - dev.update = AsyncMock() - dev.disconnect = AsyncMock() - with ( - patch.object(_smart_plug, "Discover") as mock_discover, - patch.object(_smart_plug, "Credentials") as mock_creds, - ): - mock_discover.discover_single = AsyncMock(return_value=dev) - result = asyncio.run( - _connect({"host": "1.2.3.4", "email": "e", "password": "p"}) - ) - assert result is dev - mock_creds.assert_called_once_with("e", "p") - - def test_returns_none_when_discover_raises_oserror(self) -> None: - """OSError during discovery returns None.""" - with patch.object(_smart_plug, "Discover") as mock_discover: - mock_discover.discover_single = AsyncMock(side_effect=OSError) - result = asyncio.run(_connect({"host": "h", "email": "e", "password": "p"})) - assert result is None - - def test_returns_none_when_update_raises(self) -> None: - """Failure during update returns None and attempts disconnect.""" - dev = MagicMock() - dev.update = AsyncMock(side_effect=OSError) - dev.disconnect = AsyncMock() - with patch.object(_smart_plug, "Discover") as mock_discover: - mock_discover.discover_single = AsyncMock(return_value=dev) - result = asyncio.run(_connect({"host": "h", "email": "e", "password": "p"})) - assert result is None - dev.disconnect.assert_awaited_once() - - def test_swallows_disconnect_failure_after_update_error(self) -> None: - """A disconnect error after a failed update is suppressed.""" - dev = MagicMock() - dev.update = AsyncMock(side_effect=OSError) - dev.disconnect = AsyncMock(side_effect=OSError) - with patch.object(_smart_plug, "Discover") as mock_discover: - mock_discover.discover_single = AsyncMock(return_value=dev) - result = asyncio.run(_connect({"host": "h", "email": "e", "password": "p"})) - assert result is None - - -# --------------------------------------------------------------------------- -# _set_state -# --------------------------------------------------------------------------- - - -class TestSetState: - """Tests for _set_state().""" - - def test_noop_when_config_missing(self) -> None: - """No config => no kasa calls.""" - with ( - patch.object(_smart_plug, "_load_config", return_value=None), - patch.object(_smart_plug, "_connect") as mock_connect, - ): - asyncio.run(_set_state(on=True)) - mock_connect.assert_not_called() - - def test_noop_when_connect_returns_none(self) -> None: - """Connect failure => no toggle.""" - with ( - patch.object( - _smart_plug, - "_load_config", - return_value={"host": "h", "email": "e", "password": "p"}, - ), - patch.object(_smart_plug, "_connect", new=AsyncMock(return_value=None)), - ): - asyncio.run(_set_state(on=True)) - - def test_turns_on_when_on_true(self) -> None: - """on=True calls dev.turn_on(), not turn_off().""" - dev = MagicMock() - dev.turn_on = AsyncMock() - dev.turn_off = AsyncMock() - dev.disconnect = AsyncMock() - with ( - patch.object( - _smart_plug, - "_load_config", - return_value={"host": "h", "email": "e", "password": "p"}, - ), - patch.object(_smart_plug, "_connect", new=AsyncMock(return_value=dev)), - ): - asyncio.run(_set_state(on=True)) - dev.turn_on.assert_awaited_once() - dev.turn_off.assert_not_called() - dev.disconnect.assert_awaited_once() - - def test_turns_off_when_on_false(self) -> None: - """on=False calls dev.turn_off(), not turn_on().""" - dev = MagicMock() - dev.turn_on = AsyncMock() - dev.turn_off = AsyncMock() - dev.disconnect = AsyncMock() - with ( - patch.object( - _smart_plug, - "_load_config", - return_value={"host": "h", "email": "e", "password": "p"}, - ), - patch.object(_smart_plug, "_connect", new=AsyncMock(return_value=dev)), - ): - asyncio.run(_set_state(on=False)) - dev.turn_off.assert_awaited_once() - dev.turn_on.assert_not_called() - - def test_swallows_toggle_oserror_and_still_disconnects(self) -> None: - """A toggle OSError is swallowed; disconnect still runs.""" - dev = MagicMock() - dev.turn_on = AsyncMock(side_effect=OSError) - dev.disconnect = AsyncMock() - with ( - patch.object( - _smart_plug, - "_load_config", - return_value={"host": "h", "email": "e", "password": "p"}, - ), - patch.object(_smart_plug, "_connect", new=AsyncMock(return_value=dev)), - ): - asyncio.run(_set_state(on=True)) - dev.disconnect.assert_awaited_once() - - def test_swallows_disconnect_oserror(self) -> None: - """A disconnect OSError after a successful toggle is suppressed.""" - dev = MagicMock() - dev.turn_on = AsyncMock() - dev.disconnect = AsyncMock(side_effect=OSError) - with ( - patch.object( - _smart_plug, - "_load_config", - return_value={"host": "h", "email": "e", "password": "p"}, - ), - patch.object(_smart_plug, "_connect", new=AsyncMock(return_value=dev)), - ): - asyncio.run(_set_state(on=True)) - - -# --------------------------------------------------------------------------- -# _run, turn_on_plug, turn_off_plug -# --------------------------------------------------------------------------- - - -class TestRun: - """Tests for _run() and the sync wrappers.""" - - def test_noop_when_kasa_unavailable(self) -> None: - """When kasa import failed, _run returns silently.""" - with ( - patch.object(_smart_plug, "_KASA_AVAILABLE", new=False), - patch.object(_smart_plug, "_set_state") as mock_set_state, - ): - _run(on=True) - mock_set_state.assert_not_called() - - @pytest.mark.usefixtures("_kasa_available") - def test_invokes_set_state(self) -> None: - """When kasa is available, _set_state runs via asyncio.run.""" - with patch.object(_smart_plug, "_set_state", new=AsyncMock()) as mock_set_state: - _run(on=True) - mock_set_state.assert_awaited_once_with(on=True) - - @pytest.mark.usefixtures("_kasa_available") - def test_swallows_timeout(self) -> None: - """A timeout from asyncio.wait_for is suppressed.""" - - async def _hang(**_: bool) -> None: - await asyncio.sleep(10) - - with ( - patch.object(_smart_plug, "_set_state", new=_hang), - patch.object(_smart_plug, "TAPO_TIMEOUT_SECONDS", 0.01), - ): - _run(on=True) - - @pytest.mark.usefixtures("_kasa_available") - def test_swallows_oserror(self) -> None: - """An OSError raised from _set_state is suppressed.""" - with patch.object( - _smart_plug, "_set_state", new=AsyncMock(side_effect=OSError) - ): - _run(on=True) - - @pytest.mark.usefixtures("_kasa_available") - def test_swallows_runtimeerror(self) -> None: - """A RuntimeError raised from _set_state is suppressed.""" - with patch.object( - _smart_plug, "_set_state", new=AsyncMock(side_effect=RuntimeError) - ): - _run(on=True) - - @pytest.mark.usefixtures("_kasa_available") - def test_turn_on_plug_delegates(self) -> None: - """turn_on_plug calls _run with on=True.""" - with patch.object(_smart_plug, "_run") as mock_run: - turn_on_plug() - mock_run.assert_called_once_with(on=True) - - @pytest.mark.usefixtures("_kasa_available") - def test_turn_off_plug_delegates(self) -> None: - """turn_off_plug calls _run with on=False.""" - with patch.object(_smart_plug, "_run") as mock_run: - turn_off_plug() - mock_run.assert_called_once_with(on=False) - - -class TestKasaImportFallback: - """Cover the ImportError branch of the optional ``kasa`` import.""" - - def test_module_sets_kasa_unavailable_when_import_fails( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Reloading the module with ``kasa`` blocked sets _KASA_AVAILABLE=False.""" - import importlib - import sys - - monkeypatch.setitem(sys.modules, "kasa", None) - monkeypatch.setitem(sys.modules, "kasa.exceptions", None) - try: - reloaded = importlib.reload(_smart_plug) - assert reloaded._KASA_AVAILABLE is False - finally: - monkeypatch.undo() - importlib.reload(_smart_plug) diff --git a/python_pkg/wake_alarm/tests/test_state.py b/python_pkg/wake_alarm/tests/test_state.py deleted file mode 100644 index 566f851..0000000 --- a/python_pkg/wake_alarm/tests/test_state.py +++ /dev/null @@ -1,314 +0,0 @@ -"""Tests for _state.py — HMAC-signed wake state management.""" - -from __future__ import annotations - -import json -from typing import TYPE_CHECKING -from unittest.mock import patch - -import pytest - -from python_pkg.wake_alarm._state import ( - _today_str, - has_workout_skip_today, - load_wake_state, - save_wake_state, - was_alarm_dismissed_today, - was_workout_logged_today, -) - -if TYPE_CHECKING: - from pathlib import Path - - -@pytest.fixture -def wake_state_file(tmp_path: Path) -> Path: - """Provide a temporary wake state file path.""" - return tmp_path / "wake_state.json" - - -@pytest.fixture(autouse=True) -def _patch_wake_state_file(wake_state_file: Path) -> None: - """Redirect WAKE_STATE_FILE to tmp_path for all tests.""" - with patch( - "python_pkg.wake_alarm._state.WAKE_STATE_FILE", - wake_state_file, - ): - yield - - -class TestTodayStr: - """Tests for _today_str helper.""" - - def test_returns_date_string(self) -> None: - """Return a YYYY-MM-DD string for today.""" - result = _today_str() - assert len(result) == 10 - assert result[4] == "-" - assert result[7] == "-" - - -class TestSaveWakeState: - """Tests for save_wake_state.""" - - def test_saves_with_hmac(self, wake_state_file: Path) -> None: - """Save state with HMAC signature when key is available.""" - with patch( - "python_pkg.wake_alarm._state.compute_entry_hmac", - return_value="fakesig", - ): - result = save_wake_state( - dismissed_at="2026-04-12T07:04:00+00:00", - skip_workout=True, - ) - - assert result is True - data = json.loads(wake_state_file.read_text()) - assert data["skip_workout"] is True - assert data["dismissed_at"] == "2026-04-12T07:04:00+00:00" - assert data["hmac"] == "fakesig" - assert data["date"] == _today_str() - - def test_saves_without_hmac(self, wake_state_file: Path) -> None: - """Save unsigned state when HMAC key is unavailable.""" - with patch( - "python_pkg.wake_alarm._state.compute_entry_hmac", - return_value=None, - ): - result = save_wake_state( - dismissed_at=None, - skip_workout=False, - ) - - assert result is True - data = json.loads(wake_state_file.read_text()) - assert data["skip_workout"] is False - assert "hmac" not in data - - def test_returns_false_on_write_error(self, wake_state_file: Path) -> None: - """Return False when file cannot be written.""" - with ( - patch( - "python_pkg.wake_alarm._state.compute_entry_hmac", - return_value="sig", - ), - patch( - "python_pkg.wake_alarm._state.WAKE_STATE_FILE", - wake_state_file / "nonexistent_dir" / "file.json", - ), - ): - result = save_wake_state(dismissed_at=None, skip_workout=False) - - assert result is False - - -class TestLoadWakeState: - """Tests for load_wake_state.""" - - def test_returns_none_when_file_missing(self) -> None: - """Return None when state file doesn't exist.""" - assert load_wake_state() is None - - def test_returns_none_for_wrong_date( - self, - wake_state_file: Path, - ) -> None: - """Return None when state is from a different day.""" - state = {"date": "1999-01-01", "skip_workout": True, "hmac": "x"} - wake_state_file.write_text(json.dumps(state)) - assert load_wake_state() is None - - def test_returns_none_for_invalid_json( - self, - wake_state_file: Path, - ) -> None: - """Return None when file contains invalid JSON.""" - wake_state_file.write_text("not json {{{") - assert load_wake_state() is None - - def test_returns_none_for_non_dict( - self, - wake_state_file: Path, - ) -> None: - """Return None when file contains a non-dict JSON value.""" - wake_state_file.write_text(json.dumps([1, 2, 3])) - assert load_wake_state() is None - - def test_returns_none_for_bad_hmac( - self, - wake_state_file: Path, - ) -> None: - """Return None when HMAC verification fails.""" - state = { - "date": _today_str(), - "skip_workout": True, - "dismissed_at": "07:00", - "hmac": "badsig", - } - wake_state_file.write_text(json.dumps(state)) - with patch( - "python_pkg.wake_alarm._state.verify_entry_hmac", - return_value=False, - ): - assert load_wake_state() is None - - def test_returns_state_for_valid_today( - self, - wake_state_file: Path, - ) -> None: - """Return state dict when file is valid and for today.""" - state = { - "date": _today_str(), - "skip_workout": True, - "dismissed_at": "07:04", - "hmac": "validsig", - } - wake_state_file.write_text(json.dumps(state)) - with patch( - "python_pkg.wake_alarm._state.verify_entry_hmac", - return_value=True, - ): - result = load_wake_state() - - assert result is not None - assert result["skip_workout"] is True - - -class TestHasWorkoutSkipToday: - """Tests for has_workout_skip_today.""" - - def test_returns_false_when_no_state(self) -> None: - """Return False when no state file exists.""" - assert has_workout_skip_today() is False - - def test_returns_true_when_skip_granted( - self, - wake_state_file: Path, - ) -> None: - """Return True when today's state has skip_workout=True.""" - state = { - "date": _today_str(), - "skip_workout": True, - "dismissed_at": "07:04", - "hmac": "sig", - } - wake_state_file.write_text(json.dumps(state)) - with patch( - "python_pkg.wake_alarm._state.verify_entry_hmac", - return_value=True, - ): - assert has_workout_skip_today() is True - - def test_returns_false_when_skip_not_granted( - self, - wake_state_file: Path, - ) -> None: - """Return False when today's state has skip_workout=False.""" - state = { - "date": _today_str(), - "skip_workout": False, - "dismissed_at": None, - "hmac": "sig", - } - wake_state_file.write_text(json.dumps(state)) - with patch( - "python_pkg.wake_alarm._state.verify_entry_hmac", - return_value=True, - ): - assert has_workout_skip_today() is False - - -class TestWasAlarmDismissedToday: - """Tests for was_alarm_dismissed_today.""" - - def test_returns_false_when_no_state(self) -> None: - """Return False when no state file exists.""" - assert was_alarm_dismissed_today() is False - - def test_returns_true_when_dismissed( - self, - wake_state_file: Path, - ) -> None: - """Return True when alarm was dismissed today.""" - state = { - "date": _today_str(), - "dismissed_at": "07:04", - "skip_workout": True, - "hmac": "sig", - } - wake_state_file.write_text(json.dumps(state)) - with patch( - "python_pkg.wake_alarm._state.verify_entry_hmac", - return_value=True, - ): - assert was_alarm_dismissed_today() is True - - def test_returns_false_when_not_dismissed( - self, - wake_state_file: Path, - ) -> None: - """Return False when alarm was not dismissed.""" - state = { - "date": _today_str(), - "dismissed_at": None, - "skip_workout": False, - "hmac": "sig", - } - wake_state_file.write_text(json.dumps(state)) - with patch( - "python_pkg.wake_alarm._state.verify_entry_hmac", - return_value=True, - ): - assert was_alarm_dismissed_today() is False - - -class TestWasWorkoutLoggedToday: - """Tests for was_workout_logged_today.""" - - def test_returns_false_when_file_missing(self, tmp_path: Path) -> None: - """Return False when the workout log file does not exist.""" - with patch( - "python_pkg.wake_alarm._state.WORKOUT_LOG_FILE", - tmp_path / "workout_log.json", - ): - assert was_workout_logged_today() is False - - def test_returns_false_when_file_is_invalid_json(self, tmp_path: Path) -> None: - """Return False when the workout log contains invalid JSON.""" - log_file = tmp_path / "workout_log.json" - log_file.write_text("not json {{{") - with patch( - "python_pkg.wake_alarm._state.WORKOUT_LOG_FILE", - log_file, - ): - assert was_workout_logged_today() is False - - def test_returns_false_when_file_is_not_a_dict(self, tmp_path: Path) -> None: - """Return False when the workout log is not a JSON object.""" - log_file = tmp_path / "workout_log.json" - log_file.write_text(json.dumps([1, 2, 3])) - with patch( - "python_pkg.wake_alarm._state.WORKOUT_LOG_FILE", - log_file, - ): - assert was_workout_logged_today() is False - - def test_returns_false_when_today_absent(self, tmp_path: Path) -> None: - """Return False when the workout log has no entry for today.""" - log_file = tmp_path / "workout_log.json" - log_file.write_text(json.dumps({"1999-01-01": {"type": "old"}})) - with patch( - "python_pkg.wake_alarm._state.WORKOUT_LOG_FILE", - log_file, - ): - assert was_workout_logged_today() is False - - def test_returns_true_when_today_present(self, tmp_path: Path) -> None: - """Return True when today's date key exists in the workout log.""" - log_file = tmp_path / "workout_log.json" - log_file.write_text(json.dumps({_today_str(): {"type": "phone_verified"}})) - with patch( - "python_pkg.wake_alarm._state.WORKOUT_LOG_FILE", - log_file, - ): - assert was_workout_logged_today() is True diff --git a/python_pkg/wake_alarm/wake-alarm-fans.sh b/python_pkg/wake_alarm/wake-alarm-fans.sh deleted file mode 100755 index 8f014da..0000000 --- a/python_pkg/wake_alarm/wake-alarm-fans.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/bash -# Control ALL NCT pwm fan channels for the wake alarm. -# -# Usage: -# wake-alarm-fans.sh max — ramp every pwm[1-9] channel to 100% -# wake-alarm-fans.sh restore — restore the state captured by the last `max` -# -# Must be run as root (installed in /etc/sudoers.d/wake-alarm via install.sh). -# Safe: fans are designed to run at max speed indefinitely. -# -# State is stored at $STATE_FILE so `restore` doesn't need any arguments. - -set -euo pipefail - -STATE_FILE="/run/wake-alarm-fans.state" - -# Locate the hwmon directory for any NCT Super I/O fan controller. -HWMON="" -for name_file in /sys/class/hwmon/hwmon*/name; do - [[ -f "$name_file" ]] || continue - chip=$(cat "$name_file") - case "$chip" in - nct6775|nct6779|nct6791|nct6792|nct6793|nct6795|nct6796|nct6797|nct6798|nct6799) - HWMON=$(dirname "$name_file") - break - ;; - esac -done - -if [[ -z "$HWMON" ]]; then - # Not an error — hardware without this chip just skips fan control. - exit 0 -fi - -case "${1:-}" in - max) - : > "$STATE_FILE" - for pwm in "$HWMON"/pwm[0-9]; do - [[ -w "$pwm" ]] || continue - enable="${pwm}_enable" - [[ -w "$enable" ]] || continue - old_pwm=$(cat "$pwm") - old_enable=$(cat "$enable") - printf '%s %s %s\n' "$pwm" "$old_enable" "$old_pwm" >> "$STATE_FILE" - echo 1 > "$enable" # Switch to manual mode. - echo 255 > "$pwm" # 255/255 = 100% speed. - done - ;; - restore) - [[ -f "$STATE_FILE" ]] || exit 0 - while read -r pwm old_enable old_pwm; do - [[ -w "$pwm" && -w "${pwm}_enable" ]] || continue - # Restore pwm value first, then restore the control mode. - echo "$old_pwm" > "$pwm" - echo "$old_enable" > "${pwm}_enable" - done < "$STATE_FILE" - rm -f "$STATE_FILE" - ;; - *) - echo "Usage: $0 max | $0 restore" >&2 - exit 1 - ;; -esac diff --git a/python_pkg/wake_alarm/wake-alarm.service b/python_pkg/wake_alarm/wake-alarm.service deleted file mode 100644 index fe1c045..0000000 --- a/python_pkg/wake_alarm/wake-alarm.service +++ /dev/null @@ -1,20 +0,0 @@ -[Unit] -Description=Weekend Wake Alarm -After=graphical-session.target - -[Service] -Type=simple -# DISPLAY/PYTHONPATH mirror workout-locker.service: without DISPLAY the alarm -# crashes on cold boot with "no display name and no $DISPLAY" (Tk can't open -# the X server) before systemd retries. The short sleep lets the X session -# export DISPLAY into the user environment first. -Environment=DISPLAY=:0 -Environment=PYTHONPATH=%h/testsAndMisc -ExecStartPre=/bin/sleep 1 -ExecStart=/usr/bin/python -m python_pkg.wake_alarm._alarm --production -WorkingDirectory=%h/testsAndMisc -Restart=on-failure -RestartSec=10 - -[Install] -WantedBy=graphical-session.target diff --git a/python_pkg/wake_alarm/wake_state.json b/python_pkg/wake_alarm/wake_state.json deleted file mode 100644 index ff7d63a..0000000 --- a/python_pkg/wake_alarm/wake_state.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "date": "2026-06-14", - "dismissed_at": "2026-06-14T05:01:28.589654+00:00", - "skip_workout": true, - "hmac": "b472bf9b0874ff3f6f460cace7965d53cdfce823ee6f2d1f91914e43f003e92b" -}