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)
|
- [`steam-backlog-enforcer`](https://github.com/kuhyx/steam-backlog-enforcer)
|
||||||
- [`screen-locker`](https://github.com/kuhyx/screen-locker)
|
- [`screen-locker`](https://github.com/kuhyx/screen-locker)
|
||||||
- [`diet-guard`](https://github.com/kuhyx/diet-guard)
|
- [`diet-guard`](https://github.com/kuhyx/diet-guard)
|
||||||
|
- [`wake-alarm`](https://github.com/kuhyx/wake-alarm)
|
||||||
|
|
||||||
Archived / unmaintained projects live in the sibling repository
|
Archived / unmaintained projects live in the sibling repository
|
||||||
[`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive).
|
[`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive).
|
||||||
@ -34,16 +35,6 @@ Archived / unmaintained projects live in the sibling repository
|
|||||||
|
|
||||||
### Python Packages (`python_pkg/`)
|
### 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
|
- **brother_printer/** — Brother printer status checker via CUPS and USB/network query
|
||||||
- `check_brother_printer.py` — main status check
|
- `check_brother_printer.py` — main status check
|
||||||
- `cups_queue.py` / `cups_service.py` — CUPS integration
|
- `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.
|
"""Orchestrate the morning wake/workout flow as one sequential routine.
|
||||||
|
|
||||||
The wake alarm (``python_pkg.wake_alarm``) and the workout screen lock
|
The wake alarm (``wake_alarm``, https://github.com/kuhyx/wake-alarm) and the
|
||||||
(``python_pkg.screen_locker``) used to run as two independent
|
workout screen lock (``screen_locker``, https://github.com/kuhyx/screen-locker)
|
||||||
``graphical-session.target`` user services, each opening its own fullscreen
|
used to run as two independent ``graphical-session.target`` user services,
|
||||||
``-topmost`` Tk window. On a wake morning they could grab the screen at the same
|
each opening its own fullscreen ``-topmost`` Tk window. On a wake morning they
|
||||||
time, so the alarm could end up hidden behind the workout lock (or vice versa).
|
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
|
This orchestrator makes them one coherent flow by running them as **sequential
|
||||||
subprocesses**: the alarm runs first and owns the fullscreen until it is
|
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
|
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.
|
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:
|
Usage:
|
||||||
python -m python_pkg.morning_routine._orchestrator --with-alarm # resume
|
python -m python_pkg.morning_routine._orchestrator --with-alarm # resume
|
||||||
python -m python_pkg.morning_routine._orchestrator # lock only
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Modules invoked as ``python -m <module> --production``.
|
# Modules invoked as ``python -m <module> --production``.
|
||||||
ALARM_MODULE: str = "python_pkg.wake_alarm._alarm"
|
ALARM_MODULE: str = "wake_alarm._alarm"
|
||||||
WORKOUT_LOCK_MODULE: str = "python_pkg.screen_locker.screen_lock"
|
WORKOUT_LOCK_MODULE: str = "screen_locker.screen_lock"
|
||||||
|
|
||||||
|
|
||||||
def _run_module(module: str) -> int:
|
def _run_module(module: str) -> int:
|
||||||
|
|||||||
@ -3,41 +3,32 @@
|
|||||||
#
|
#
|
||||||
# What it does:
|
# What it does:
|
||||||
# 1. Installs morning-routine.service (user service, started on resume).
|
# 1. Installs morning-routine.service (user service, started on resume).
|
||||||
# 2. Reinstalls the systemd-sleep hook so resume starts morning-routine
|
# 2. Disables the standalone wake-alarm.service autostart: the orchestrator
|
||||||
# (alarm first, then the workout lock - one fullscreen owner at a time).
|
|
||||||
# 3. Disables the standalone wake-alarm.service autostart: the orchestrator
|
|
||||||
# runs the alarm now, and this also removes its evening-login firing quirk.
|
# 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
|
# Prereq: run wake_alarm's own install.sh first (https://github.com/kuhyx/wake-alarm) —
|
||||||
# sudoers entries, the fan-control script, and python-kasa.
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||||
REPO_ROOT="$(readlink -f "$SCRIPT_DIR/../..")"
|
|
||||||
SERVICE_SRC="$SCRIPT_DIR/morning-routine.service"
|
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"
|
SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
|
||||||
SLEEP_HOOK_DST="/usr/lib/systemd/system-sleep/wake-alarm.sh"
|
|
||||||
|
|
||||||
echo "=== Unified Morning Routine Installer ==="
|
echo "=== Unified Morning Routine Installer ==="
|
||||||
|
|
||||||
# 1. Install the orchestrator user service.
|
# 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"
|
mkdir -p "$SYSTEMD_USER_DIR"
|
||||||
cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/morning-routine.service"
|
cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/morning-routine.service"
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
echo " Installed to $SYSTEMD_USER_DIR/morning-routine.service"
|
echo " Installed to $SYSTEMD_USER_DIR/morning-routine.service"
|
||||||
|
|
||||||
# 2. Reinstall the sleep hook (now starts morning-routine.service on resume).
|
# 2. Disable the standalone wake-alarm.service autostart (orchestrator owns it).
|
||||||
echo "[2/3] Installing systemd-sleep hook (requires sudo)..."
|
echo "[2/2] Disabling standalone wake-alarm.service autostart..."
|
||||||
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..."
|
|
||||||
if systemctl --user cat wake-alarm.service &>/dev/null; then
|
if systemctl --user cat wake-alarm.service &>/dev/null; then
|
||||||
systemctl --user disable wake-alarm.service 2>/dev/null || true
|
systemctl --user disable wake-alarm.service 2>/dev/null || true
|
||||||
systemctl --user stop 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