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.
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-22 12:47:06 +02:00
parent 091045fd67
commit 4eac1a45fe
29 changed files with 79 additions and 4958 deletions

View File

@ -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

View File

@ -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"
]
}

View File

@ -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 <module>`` 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 <module> --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:

View File

@ -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

View File

@ -1 +0,0 @@
"""Weekend wake alarm system with escalating beep and dismiss challenge."""

View File

@ -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("<Return>", 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()

View File

@ -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,
)

View File

@ -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("<h", sample))
wav.writeframesraw(bytes(frames))
_TONE_CACHE[frequency] = path
return path
def _try_player(binary: str, wav: Path) -> 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 "<none>",
)
else:
_logger.info("Audio sinks available: %s", sink_names)

View File

@ -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),
)()

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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)

View File

@ -1 +0,0 @@
"""Tests for the wake alarm package."""

View File

@ -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

View File

@ -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()

View File

@ -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"}

View File

@ -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,
)

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,6 +0,0 @@
{
"date": "2026-06-14",
"dismissed_at": "2026-06-14T05:01:28.589654+00:00",
"skip_workout": true,
"hmac": "b472bf9b0874ff3f6f460cace7965d53cdfce823ee6f2d1f91914e43f003e92b"
}