mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 13:23:15 +02:00
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:
parent
091045fd67
commit
4eac1a45fe
11
CLAUDE.md
11
CLAUDE.md
@ -11,6 +11,7 @@ Extracted to their own repos:
|
||||
- [`steam-backlog-enforcer`](https://github.com/kuhyx/steam-backlog-enforcer)
|
||||
- [`screen-locker`](https://github.com/kuhyx/screen-locker)
|
||||
- [`diet-guard`](https://github.com/kuhyx/diet-guard)
|
||||
- [`wake-alarm`](https://github.com/kuhyx/wake-alarm)
|
||||
|
||||
Archived / unmaintained projects live in the sibling repository
|
||||
[`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive).
|
||||
@ -34,16 +35,6 @@ Archived / unmaintained projects live in the sibling repository
|
||||
|
||||
### Python Packages (`python_pkg/`)
|
||||
|
||||
- **wake_alarm/** — Alarm + fan ramp + Tapo P110 smart plug control
|
||||
- `_alarm.py` — alarm logic
|
||||
- `_smart_plug.py` — Tapo P110 control
|
||||
- `_state.py` — alarm state persistence
|
||||
- `_constants.py` — timing/config constants
|
||||
- `wake_state.json` — persistent alarm state
|
||||
- `wake-alarm-fans.sh` — fan ramp script (requires sudo)
|
||||
- `wake-alarm.service` — systemd unit
|
||||
- `tests/` — pytest tests
|
||||
|
||||
- **brother_printer/** — Brother printer status checker via CUPS and USB/network query
|
||||
- `check_brother_printer.py` — main status check
|
||||
- `cups_queue.py` / `cups_service.py` — CUPS integration
|
||||
|
||||
56
docs/superpowers/evidence/remove-wake-alarm-2026-06-22.json
Normal file
56
docs/superpowers/evidence/remove-wake-alarm-2026-06-22.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1 +0,0 @@
|
||||
"""Weekend wake alarm system with escalating beep and dismiss challenge."""
|
||||
@ -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()
|
||||
@ -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,
|
||||
)
|
||||
@ -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)
|
||||
@ -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),
|
||||
)()
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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"
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -1 +0,0 @@
|
||||
"""Tests for the wake alarm package."""
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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"}
|
||||
@ -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,
|
||||
)
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"date": "2026-06-14",
|
||||
"dismissed_at": "2026-06-14T05:01:28.589654+00:00",
|
||||
"skip_workout": true,
|
||||
"hmac": "b472bf9b0874ff3f6f460cace7965d53cdfce823ee6f2d1f91914e43f003e92b"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user