morning_routine: unified alarm+lock orchestrator, fix alarm audio/reliability

- New python_pkg/morning_routine package: sequential orchestrator runs wake
  alarm then workout lock as blocking subprocesses (one fullscreen owner at
  a time). Deployed as morning-routine.service; sleep hook updated to start
  it on hibernate-resume instead of the standalone wake-alarm.service.

- wake_alarm: force G27Q HDMI card profile on at alarm time, poll up to 6s
  for sink to appear, set as default + unmute 100%. Alarm now persists until
  the typeable code is entered (no more silent 30-min give-up). Service gets
  DISPLAY=:0 + ExecStartPre sleep 1 to fix cold-boot Tkinter crash.

- phone_focus_mode/config.sh: whitelist Revolut, mObywatel, VaultKitBypass.

- 100% branch coverage maintained across wake_alarm and morning_routine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-25 18:55:27 +02:00
parent 60e855d1db
commit c80f7cc112
18 changed files with 999 additions and 331 deletions

1
.gitignore vendored
View File

@ -359,3 +359,4 @@ out.txt
phone_focus_mode/focus_status_app/build
phone_focus_mode/focus_status_app/debug.keystore
.worktrees
.claude/

266
CLAUDE.md Normal file
View File

@ -0,0 +1,266 @@
# Copilot Instructions for testsAndMisc
## Project Overview
A mixed-language monorepo containing Python packages, Bash scripts, and misc automation. Actively-developed
components span personal productivity tools: Steam backlog management, alarm/shutdown scheduling, screen
locking, Linux system configuration, and Android phone focus enforcement.
Archived / unmaintained projects live in the sibling repository
[`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive).
## Repository Layout
| Path | Description |
| ---------------------- | ---------------------------------------------------------------------------------------------- |
| `python_pkg/` | Python packages — each maintained subpackage lives here |
| `linux_configuration/` | Arch Linux setup, i3 config, system maintenance scripts |
| `phone_focus_mode/` | GPS-based Android focus enforcer (Bash, ADB, Magisk) |
| `meta/` | Repo-wide tooling: `pyproject.toml`, `requirements.txt`, `run.sh`, `lint_python.sh`, `.fvmrc` |
| `scripts/` | Workspace-level helper scripts and pre-commit hooks (moved to `meta/scripts/`) |
| `docs/` | Reference docs; `docs/superpowers/` holds AI workflow artifacts |
| `third_party/` | Vendored upstream skills/agents |
> **Note**: Root-level `pyproject.toml`, `requirements.txt`, `requirements.txt`, `run.sh`, and `.fvmrc`
> are symlinks into `meta/`. Edit files there, not the symlinks.
## Architecture
### Python Packages (`python_pkg/`)
- **steam_backlog_enforcer/** — Steam game backlog enforcer with HLTB hour tracking, game library hider,
ProtonDB compatibility checks, and installation automation
- `main.py` — entry point and enforce loop
- `enforcer.py` — core enforcement logic
- `hltb.py` / `_hltb_search.py` / `_hltb_detail.py` — HowLongToBeat API integration
- `steam_api.py` — Steam library and install API
- `library_hider.py` — hides non-current games from Steam
- `protondb.py` — ProtonDB compatibility rating lookup
- `scanning.py` — scan and select next game
- `tests/` — pytest tests; requires real Steam state; `conftest.py` redirects all paths to tmp_path
- **screen_locker/** — Tkinter/systemd screen locker with workout tracking and sick-day management
- `screen_lock.py` — main locker UI
- `_early_bird.py` — early-bird workout check
- `_sick_tracker.py` — sick-day tracker
- `_shutdown.py` — scheduled shutdown integration
- `_phone_verification.py` — phone check integration
- `_ui_flows.py` / `_window_setup.py` — UI helpers
- `_time_check.py` — time/schedule checks
- `_log_integrity.py` — tamper-evident workout logs
- `tests/` — 100% branch coverage enforced (300+ tests)
- **wake_alarm/** — Alarm + fan ramp + Tapo P110 smart plug control
- `_alarm.py` — alarm logic
- `_smart_plug.py` — Tapo P110 control
- `_state.py` — alarm state persistence
- `_constants.py` — timing/config constants
- `wake_state.json` — persistent alarm state
- `wake-alarm-fans.sh` — fan ramp script (requires sudo)
- `wake-alarm.service` — systemd unit
- `tests/` — pytest tests
- **brother_printer/** — Brother printer status checker via CUPS and USB/network query
- `check_brother_printer.py` — main status check
- `cups_queue.py` / `cups_service.py` — CUPS integration
- `network_query.py` / `usb_query.py` — device discovery
- `tests/` — pytest tests
- **screen_locker** and **wake_alarm** share the `midnight_shutdown` integration:
on alarm nights the system hibernates instead of powering off.
- **shared/** — Shared utilities across python_pkg subpackages
- **random_jpg/** — Random JPEG downloader utility
- **geo_cache/** — Geographic coordinate cache helper
### Phone Focus Mode (`phone_focus_mode/`)
Location-based app restriction for rooted Android. Automatically disables non-whitelisted apps within
500 m of home using ADB + Magisk.
- `focus_ctl.sh` / `focus_daemon.sh` — focus enforcement scripts
- `dns_enforcer.sh` — DNS-level blocking (netd cache restart for YouTube)
- `hosts_enforcer.sh``/etc/hosts` manipulation
- `launcher_enforcer.sh` — launcher restriction
- `workout_detector.sh` — workout detection integration
- `magisk_service.sh` — Magisk module hook (prevents module self-disabling)
- `config.sh` — configuration constants
- `deploy.sh` — ADB deployment script
- `systemd/` — systemd units for scheduling
- `lib/` — shared shell library functions
### Linux Configuration (`linux_configuration/`)
Arch Linux setup and ongoing system automation.
```
linux_configuration/
├── install_core_system.sh # Core system installer
├── scripts/
│ ├── single_use/ # One-time setup scripts
│ │ ├── fresh-install/
│ │ ├── features/
│ │ ├── fixes/
│ │ └── misc/
│ └── periodic_background/ # Ongoing daemons / scheduled scripts
│ ├── digital_wellbeing/ # Compulsive-opening blocker, focus daemon, LeechBlock
│ ├── hosts/ # DNS/hosts guard and generation
│ ├── i3-configuration/ # i3 window manager config
│ ├── system-maintenance/ # Usage reporting, system checks
│ └── utils/
├── tests/ # Shell-based test harness
└── test_results.log
```
Key scripts:
- `scripts/periodic_background/digital_wellbeing/focus_mode_daemon.py` — Linux digital-wellbeing daemon
- `scripts/periodic_background/hosts/generate_hosts_file.sh` — Generates `/etc/hosts` blocklist
- `scripts/periodic_background/system-maintenance/bin/usage_report.py` — Daily usage report
### `meta/` — Repo-wide Tooling
All root-level config files are symlinks into `meta/`. Edit here:
- `meta/pyproject.toml` — ruff, mypy, pylint, bandit, pytest, coverage config
- `meta/requirements.txt` — runtime + dev dependencies
- `meta/run.sh` — usage report entrypoint + polling script profiler/diagnostics
- `meta/lint_python.sh` — manual lint helper
- `meta/scripts/` — pre-commit hook scripts (`check_no_binaries.sh`, `check_ai_evidence.sh`, etc.)
### `docs/superpowers/` — AI Workflow Artifacts
Pre-commit **requires** an evidence file for every commit that changes source code:
```
docs/superpowers/
├── evidence/ # ← Required: one JSON per commit touching code
│ └── template.json
├── contracts/ # Acceptance criteria / objective contracts per task
├── sessions/ # Append-only session logs (JSONL)
├── specs/ # Task specifications / design docs
├── plans/ # Implementation plans
├── memory/ # Persistent context (CONTEXT.md, etc.)
└── workflows/ # Agent workflow definitions
```
**Rule**: copy `docs/superpowers/evidence/template.json`, fill it in, and stage it with your code changes
before committing. The `ai-evidence-contract` hook will reject commits without it.
## Development Workflow
### Testing (Critical — 100% branch coverage enforced for all packages)
```bash
# Run all python_pkg tests with coverage
python -m pytest python_pkg/ --cov=python_pkg --cov-branch --cov-fail-under=100
# Run a single package
python -m pytest python_pkg/steam_backlog_enforcer/ --cov=python_pkg.steam_backlog_enforcer --cov-branch --cov-fail-under=100
python -m pytest python_pkg/screen_locker/ --cov=python_pkg.screen_locker --cov-branch --cov-fail-under=100
python -m pytest python_pkg/wake_alarm/ --cov=python_pkg.wake_alarm --cov-branch --cov-fail-under=100
python -m pytest python_pkg/brother_printer/ --cov=python_pkg.brother_printer --cov-branch --cov-fail-under=100
# Quick run without coverage
python -m pytest python_pkg/ -x -v
```
### Pre-commit Hooks (Always run before commits)
```bash
pre-commit run --files <file1> <file2> # Check specific files (recommended)
pre-commit run --all-files # Full check (~10s linters)
pre-commit run --all-files --hook-stage pre-push # Includes pytest + prettier
```
**Active hooks (commit-stage)**:
| Hook | Purpose |
|------|---------|
| trailing-whitespace, end-of-file-fixer, check-yaml/json/toml/xml | General formatting |
| check-added-large-files (max 2 MB) | Prevent large files |
| detect-private-key | Secret detection |
| no-binaries | Block binary/image files from being committed |
| ai-evidence-contract | Require `docs/superpowers/evidence/*.json` for code changes |
| ai-multifile-contract | Require workflow contract for multi-file changes |
| append-only-sessions | Enforce append-only session logs |
| no-polling-antipatterns | Block polling script fork-storm anti-patterns |
| no-noqa / no-ruff-noqa | Block lint suppression comments |
| ruff (lint+fix) | Python linting |
| ruff-format | Python formatting |
| mypy | Python type checking |
| pylint | Python extended linting |
| bandit | Python security checks |
| codespell | Spell checking |
**Push-stage only**: `pytest-coverage` + `prettier`
**CRITICAL: NEVER use `--no-verify`** on `git commit` or `git push`. Fix failures or ask — never bypass hooks.
### AI Evidence Requirement
For every commit that touches `.py`, `.sh`, `.c`, `.go`, `.ts`, etc.:
1. Copy `docs/superpowers/evidence/template.json``docs/superpowers/evidence/<task-slug>-<date>.json`
2. Fill in the fields (objective, steps taken, verification result)
3. Stage and include it with your code changes
### Linting Tools (configured in `meta/pyproject.toml`)
- **ruff**: `select = ["ALL"]` — all rules enabled, Google docstrings
- **mypy**: `strict = true` with full type checking
- **pylint**: all checks enabled
- **coverage**: `fail_under = 100`, branch coverage required
## Code Conventions
### Python Style
- Use `from __future__ import annotations` for forward references
- Google docstring convention
- Absolute imports only (`ban-relative-imports = "all"`)
- Type hints required on all functions
- Private functions/modules prefixed with `_` (e.g., `_smart_plug.py`, `_process_game_event`)
### Shell Style
- Always `set -euo pipefail`
- Double-quote all variable expansions
- Avoid fork-heavy patterns: prefer `/proc`, `/sys`, bash builtins over `$(...)` in hot paths
- Use `jq`/`yq` for JSON/YAML, not `grep`/`awk`
### Test Patterns
```python
# Type aliases for test dicts (keeps mypy happy)
Event = dict[str, Any]
# Mock external calls — never hit real APIs/filesystem/Steam
with patch("python_pkg.steam_backlog_enforcer.main.some_func") as mock:
...
# Use PropertyMock for property exceptions
type(mock_obj).property_name = PropertyMock(side_effect=TypeError())
# steam_backlog_enforcer: conftest.py redirects ALL paths to tmp_path
# Never assume real state.json or steamapps path is safe to touch in tests
```
### Branch Coverage Tips
- Use explicit `while True` + `try/except StopIteration` instead of `for` loops when iterator
exhaustion needs coverage
- Mock threads/subprocesses to avoid slow tests
- Every `if`/`else` branch needs a corresponding test
## Key Files
- `meta/pyproject.toml` — All tool configs (ruff, mypy, pylint, pytest, coverage)
- `.pre-commit-config.yaml` — Pre-commit hook definitions
- `meta/requirements.txt` — Runtime + dev dependencies
- `.github/workflows/python-tests.yml` — CI: runs all pytest on `python_pkg/**` changes
- `.github/workflows/pre-commit.yml` — CI: runs pre-commit checks
- `docs/superpowers/evidence/template.json` — Template for AI evidence artifacts
## Per-File Ignores (in `meta/pyproject.toml`)
Test files allow: `S101` (assert), `PLR2004` (magic values), `S310`, `S607`, `PLC0415`

View File

@ -0,0 +1,18 @@
{
"title": "Unified morning routine: alarm + workout lock orchestrator",
"objective": "Replace the fragile standalone wake_alarm service with a unified orchestrator that runs alarm then workout lock sequentially, fixing the silent/missed alarm caused by missing HDMI audio activation and 30-min silent give-up. One service owns the fullscreen at a time.",
"acceptance_criteria": [
"morning-routine.service runs alarm (blocks until dismissed) then workout lock",
"Alarm forces G27Q HDMI card profile on and polls for sink before playing audio",
"Alarm persists until typeable code is entered (no more silent give-up after 30 min)",
"wake-alarm.service autostart disabled; sleep hook starts morning-routine.service on resume",
"100% branch coverage maintained for wake_alarm and morning_routine packages",
"All pre-commit hooks pass (ruff, mypy, pylint, bandit, shellcheck)"
],
"out_of_scope": [
"Changing workout-locker.service login-time behavior",
"Supporting audio outputs other than G27Q HDMI",
"Bluetooth speaker reconnection at wake time"
],
"verifier": "pre-commit run --files <all changed files> && systemctl --user start morning-routine.service"
}

View File

@ -0,0 +1,41 @@
{
"intent": "Fix silent/missed wake alarm and unify alarm + workout lock into one orchestrated morning flow.",
"scope": [
"python_pkg/wake_alarm: audio activation, persist-until-dismissed, DISPLAY env fix",
"python_pkg/morning_routine: new orchestrator package (alarm then lock, sequential)",
"phone_focus_mode/config.sh: add Revolut, mObywatel, VaultKitBypass to whitelist",
"Non-goal: changing workout-locker.service login-time behavior"
],
"changes": [
"wake_alarm/_constants.py: add 5 ALARM_AUDIO_* constants for G27Q HDMI output",
"wake_alarm/_alarm.py: replace max-volume-snapshot with _activate_alarm_audio (force HDMI card profile, poll 6s for sink, set default, unmute 100%) + _restore_alarm_audio; alarm persists until code typed (_skip_earnable flag, _on_skip_window_expired); wake-alarm.service gains DISPLAY=:0 + ExecStartPre sleep 1",
"wake_alarm/sleep-hook.sh: on resume start morning-routine.service instead of wake-alarm.service",
"morning_routine/_orchestrator.py: new package; runs alarm then workout-lock as sequential blocking subprocesses; --with-alarm flag; morning-routine.service + install.sh",
"phone_focus_mode/config.sh: whitelist Revolut, pl.nask.mobywatel, com.kuhy.vaultkitbypass"
],
"verification": [
{
"command": "pre-commit run --files python_pkg/wake_alarm/_alarm.py python_pkg/wake_alarm/_constants.py python_pkg/wake_alarm/sleep-hook.sh python_pkg/wake_alarm/tests/test_alarm.py python_pkg/wake_alarm/tests/test_alarm_part2.py python_pkg/wake_alarm/wake-alarm.service python_pkg/morning_routine/__init__.py python_pkg/morning_routine/_orchestrator.py python_pkg/morning_routine/morning-routine.service python_pkg/morning_routine/install.sh python_pkg/morning_routine/tests/__init__.py python_pkg/morning_routine/tests/test_orchestrator.py phone_focus_mode/config.sh",
"result": "pass",
"evidence": "All hooks pass: ruff lint+fix, ruff-format, mypy, pylint, bandit, shellcheck, no-noqa, no-polling-antipatterns, ai-evidence-contract"
},
{
"command": "systemctl --user start morning-routine.service",
"result": "pass",
"evidence": "Service completed cleanly: alarm self-exited (already dismissed today), then workout lock self-exited (skip earned). morning-routine.service exited 0."
},
{
"command": "grep morning-routine /usr/lib/systemd/system-sleep/wake-alarm.sh",
"result": "pass",
"evidence": "Installed sleep hook references morning-routine.service (not old wake-alarm.service)"
}
],
"risks": [
"HDMI sink may not appear within 6s if monitor takes longer to wake from DPMS — alarm fires silently but still shows on screen",
"First real-alarm-day test is next alarm night; audio only fully verified in interactive demo run"
],
"rollback": [
"systemctl --user disable morning-routine.service; systemctl --user enable wake-alarm.service",
"sudo cp python_pkg/wake_alarm/sleep-hook.sh.bak /usr/lib/systemd/system-sleep/wake-alarm.sh (if backup exists) or reinstall via wake_alarm/install.sh"
]
}

View File

@ -273,9 +273,14 @@ com.kolejeslaskie.mss
# --- Banking (must always work) ---
pl.mbank
pl.pkobp.iko
com.revolut.revolut
# --- Government / digital ID (Polish mObywatel — must always work) ---
pl.nask.mobywatel
# --- Security & root tools (must always work) ---
com.topjohnwu.magisk
com.kuhy.vaultkitbypass
moe.shizuku.privileged.api
me.phh.superuser
com.beemdevelopment.aegis

View File

@ -0,0 +1 @@
"""Unified morning routine: wake alarm + workout screen lock, in one flow."""

View File

@ -0,0 +1,96 @@
"""Orchestrate the morning wake/workout flow as one sequential routine.
The wake alarm (``python_pkg.wake_alarm``) and the workout screen lock
(``python_pkg.screen_locker``) used to run as two independent
``graphical-session.target`` user services, each opening its own fullscreen
``-topmost`` Tk window. On a wake morning they could grab the screen at the same
time, so the alarm could end up hidden behind the workout lock (or vice versa).
This orchestrator makes them one coherent flow by running them as **sequential
subprocesses**: the alarm runs first and owns the fullscreen until it is
dismissed, then the workout lock runs. Only one fullscreen window is ever alive
at a time, so they can never collide. Each subprocess still self-gates (the
alarm only fires on alarm days when undismissed; the lock exits if a skip was
earned or the workout is already logged), so this is safe to run on every wake.
Usage:
python -m python_pkg.morning_routine._orchestrator --with-alarm # resume
python -m python_pkg.morning_routine._orchestrator # lock only
"""
from __future__ import annotations
import argparse
import logging
import subprocess
import sys
_logger = logging.getLogger(__name__)
# Modules invoked as ``python -m <module> --production``.
ALARM_MODULE: str = "python_pkg.wake_alarm._alarm"
WORKOUT_LOCK_MODULE: str = "python_pkg.screen_locker.screen_lock"
def _run_module(module: str) -> int:
"""Run *module* as a blocking ``python -m`` subprocess in production mode.
Args:
module: Dotted module path to execute with ``python -m``.
Returns:
The subprocess exit code, or ``1`` when the process could not start.
"""
cmd = [sys.executable, "-m", module, "--production"]
_logger.info("morning-routine: running %s", module)
try:
result = subprocess.run(cmd, check=False)
except OSError:
_logger.warning("Failed to run %s", module, exc_info=True)
return 1
return result.returncode
def _run_alarm() -> int:
"""Run the wake alarm and block until it is dismissed (or self-exits)."""
return _run_module(ALARM_MODULE)
def _run_workout_lock() -> int:
"""Run the workout screen lock after the alarm has been dealt with."""
return _run_module(WORKOUT_LOCK_MODULE)
def _parse_args(argv: list[str]) -> argparse.Namespace:
"""Parse CLI arguments for the orchestrator."""
parser = argparse.ArgumentParser(description="Unified morning routine.")
parser.add_argument(
"--with-alarm",
action="store_true",
help="Run the wake alarm before the workout lock (used on resume).",
)
parser.add_argument(
"--production",
action="store_true",
help="Production mode (kept for systemd/CLI symmetry).",
)
return parser.parse_args(argv)
def main() -> None:
"""Entry point: optionally run the alarm, then always run the workout lock."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
)
args = _parse_args(sys.argv[1:])
# Alarm first so it owns the fullscreen and escalates until dismissed; only
# then hand off to the workout lock. Running them in this order in a single
# process guarantees they never fight for the screen.
if args.with_alarm:
_run_alarm()
_run_workout_lock()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,52 @@
#!/bin/bash
# Install the unified morning routine: wake alarm -> workout lock as one flow.
#
# What it does:
# 1. Installs morning-routine.service (user service, started on resume).
# 2. Reinstalls the systemd-sleep hook so resume starts morning-routine
# (alarm first, then the workout lock - one fullscreen owner at a time).
# 3. Disables the standalone wake-alarm.service autostart: the orchestrator
# 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.
#
# Prereq: run python_pkg/wake_alarm/install.sh first for the rtcwake/fan
# sudoers entries, the fan-control script, and python-kasa.
set -euo pipefail
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
REPO_ROOT="$(readlink -f "$SCRIPT_DIR/../..")"
SERVICE_SRC="$SCRIPT_DIR/morning-routine.service"
SLEEP_HOOK_SRC="$REPO_ROOT/python_pkg/wake_alarm/sleep-hook.sh"
SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
SLEEP_HOOK_DST="/usr/lib/systemd/system-sleep/wake-alarm.sh"
echo "=== Unified Morning Routine Installer ==="
# 1. Install the orchestrator user service.
echo "[1/3] Installing morning-routine.service..."
mkdir -p "$SYSTEMD_USER_DIR"
cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/morning-routine.service"
systemctl --user daemon-reload
echo " Installed to $SYSTEMD_USER_DIR/morning-routine.service"
# 2. Reinstall the sleep hook (now starts morning-routine.service on resume).
echo "[2/3] Installing systemd-sleep hook (requires sudo)..."
sudo cp "$SLEEP_HOOK_SRC" "$SLEEP_HOOK_DST"
sudo chmod 0755 "$SLEEP_HOOK_DST"
echo " Installed to $SLEEP_HOOK_DST"
# 3. Disable the standalone wake-alarm.service autostart (orchestrator owns it).
echo "[3/3] Disabling standalone wake-alarm.service autostart..."
if systemctl --user cat wake-alarm.service &>/dev/null; then
systemctl --user disable wake-alarm.service 2>/dev/null || true
systemctl --user stop wake-alarm.service 2>/dev/null || true
echo " wake-alarm.service autostart disabled (alarm runs via orchestrator)"
else
echo " wake-alarm.service not installed; nothing to disable"
fi
echo "=== Installation complete ==="
echo "On resume the morning routine runs: wake alarm -> workout lock."
echo "Test now:"
echo " python -m python_pkg.morning_routine._orchestrator --with-alarm --production"

View File

@ -0,0 +1,20 @@
[Unit]
Description=Unified Morning Routine (wake alarm + workout lock)
After=graphical-session.target
[Service]
Type=simple
# Started by the systemd-sleep hook on resume (NOT auto-started at login), so
# there is intentionally no [Install] WantedBy=graphical-session.target: the
# alarm must only run on a real wake, never on an ordinary evening login.
# DISPLAY/PYTHONPATH mirror workout-locker.service so Tk can open the X server;
# the short sleep lets the resumed session export DISPLAY first.
Environment=DISPLAY=:0
Environment=PYTHONPATH=%h/testsAndMisc
ExecStartPre=/bin/sleep 1
ExecStart=/usr/bin/python -m python_pkg.morning_routine._orchestrator --with-alarm --production
WorkingDirectory=%h/testsAndMisc
Restart=on-failure
RestartSec=2s
RestartPreventExitStatus=0
User=%u

View File

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

View File

@ -0,0 +1,102 @@
"""Tests for the unified morning routine orchestrator."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from python_pkg.morning_routine._orchestrator import (
ALARM_MODULE,
WORKOUT_LOCK_MODULE,
_parse_args,
_run_alarm,
_run_module,
_run_workout_lock,
main,
)
_ORCH = "python_pkg.morning_routine._orchestrator"
class TestRunModule:
"""Tests for _run_module."""
def test_returns_subprocess_returncode(self) -> None:
"""Builds a `python -m <module> --production` command and returns rc."""
proc = MagicMock(returncode=0)
with patch(f"{_ORCH}.subprocess.run", return_value=proc) as mock_run:
assert _run_module("some.module") == 0
cmd = mock_run.call_args.args[0]
assert cmd[1:] == ["-m", "some.module", "--production"]
def test_nonzero_returncode_propagates(self) -> None:
"""A non-zero subprocess exit code is returned unchanged."""
proc = MagicMock(returncode=3)
with patch(f"{_ORCH}.subprocess.run", return_value=proc):
assert _run_module("m") == 3
def test_oserror_returns_one(self) -> None:
"""If the subprocess cannot start, return 1 instead of raising."""
with patch(f"{_ORCH}.subprocess.run", side_effect=OSError("boom")):
assert _run_module("m") == 1
class TestRunHelpers:
"""Tests for _run_alarm and _run_workout_lock."""
def test_run_alarm_runs_alarm_module(self) -> None:
"""_run_alarm delegates to _run_module with the alarm module."""
with patch(f"{_ORCH}._run_module", return_value=0) as mock_run:
assert _run_alarm() == 0
mock_run.assert_called_once_with(ALARM_MODULE)
def test_run_workout_lock_runs_lock_module(self) -> None:
"""_run_workout_lock delegates to _run_module with the lock module."""
with patch(f"{_ORCH}._run_module", return_value=0) as mock_run:
assert _run_workout_lock() == 0
mock_run.assert_called_once_with(WORKOUT_LOCK_MODULE)
class TestParseArgs:
"""Tests for _parse_args."""
def test_with_alarm_flag(self) -> None:
"""--with-alarm sets with_alarm True."""
assert _parse_args(["--with-alarm"]).with_alarm is True
def test_default_no_alarm(self) -> None:
"""No flag leaves with_alarm False."""
assert _parse_args([]).with_alarm is False
def test_production_flag(self) -> None:
"""--production is accepted."""
assert _parse_args(["--production"]).production is True
class TestMain:
"""Tests for main() sequencing."""
def test_with_alarm_runs_alarm_then_lock(self) -> None:
"""--with-alarm runs the alarm first, then the workout lock, in order."""
manager = MagicMock()
with (
patch(f"{_ORCH}._run_alarm", manager.alarm),
patch(f"{_ORCH}._run_workout_lock", manager.lock),
patch(f"{_ORCH}.sys") as mock_sys,
patch(f"{_ORCH}.logging.basicConfig"),
):
mock_sys.argv = ["orch", "--with-alarm"]
main()
assert [call[0] for call in manager.mock_calls] == ["alarm", "lock"]
def test_without_alarm_runs_only_lock(self) -> None:
"""Without --with-alarm, the alarm is skipped and only the lock runs."""
with (
patch(f"{_ORCH}._run_alarm") as mock_alarm,
patch(f"{_ORCH}._run_workout_lock") as mock_lock,
patch(f"{_ORCH}.sys") as mock_sys,
patch(f"{_ORCH}.logging.basicConfig"),
):
mock_sys.argv = ["orch"]
main()
mock_alarm.assert_not_called()
mock_lock.assert_called_once()

View File

@ -27,6 +27,11 @@ import tkinter as tk
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,
ALARM_DAYS,
DISMISS_CODE_LENGTH,
DISMISS_CODE_REFRESH_SECONDS,
@ -461,12 +466,13 @@ class WakeAlarm:
self.root.update_idletasks()
self._current_code = _generate_code()
self._skip_earnable: bool = True
self._build_ui()
self._schedule_code_refresh()
self._schedule_dismiss_window_close()
self._schedule_skip_window_close()
self._start_beep_thread()
self._fan_state: bool = _max_fans()
self._sink_volume_state: tuple[str, str, bool] | None = _max_sink_volume()
self._audio_restore: str | None = _activate_alarm_audio()
self._flash_on: bool = False
self._start_screen_flash()
@ -535,7 +541,7 @@ class WakeAlarm:
"""Handle code submission."""
entered = self._entry.get().strip()
if entered == self._current_code:
self._dismiss_alarm(earned_skip=True)
self._dismiss_alarm(earned_skip=self._skip_earnable)
else:
self._status_label.configure(text="Wrong code! Try again.")
self._entry.delete(0, tk.END)
@ -572,7 +578,7 @@ class WakeAlarm:
"""Close the alarm window."""
self._stop_beep.set()
_restore_fans(active=self._fan_state)
_restore_sink_volume(self._sink_volume_state)
_restore_alarm_audio(self._audio_restore)
_restore_display()
turn_off_plug()
self.root.destroy()
@ -587,55 +593,45 @@ class WakeAlarm:
ms = DISMISS_CODE_REFRESH_SECONDS * 1000 if not self.demo_mode else 10_000
self.root.after(ms, self._schedule_code_refresh)
def _schedule_dismiss_window_close(self) -> None:
"""Close dismiss window after the allowed time."""
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_dismiss_window_expired)
self.root.after(ms, self._on_skip_window_expired)
def _on_dismiss_window_expired(self) -> None:
"""Called when the dismiss window expires without valid dismissal."""
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._active = False
self._stop_beep.set()
save_wake_state(dismissed_at=None, skip_workout=False)
_logger.info("Dismiss window expired — no workout skip.")
for widget in self._container.winfo_children():
widget.destroy()
tk.Label(
self._container,
text="Too late! No workout skip today.",
font=("Arial", 36, "bold"),
fg="#ff4444",
bg="#1a1a1a",
).pack(pady=30)
self.root.after(5000, self._close_and_schedule_fallback)
def _close_and_schedule_fallback(self) -> None:
"""Close the window and schedule the 1 PM fallback alarm."""
_restore_fans(active=self._fan_state)
_restore_sink_volume(self._sink_volume_state)
_restore_display()
turn_off_plug()
self.root.destroy()
self._skip_earnable = False
self._info_label.configure(
text="Skip window closed - type the code to stop the alarm",
)
self._status_label.configure(text="No workout skip today.")
_logger.info("Skip window expired - alarm continues until dismissed.")
def _update_timer(self) -> None:
"""Update the remaining time display."""
"""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)
minutes = int(remaining) // 60
seconds = int(remaining) % 60
self._timer_label.configure(
text=f"Time remaining: {minutes:02d}:{seconds:02d}",
)
if remaining > 0:
self.root.after(1000, self._update_timer)
if self._skip_earnable and remaining > 0:
minutes = int(remaining) // 60
seconds = int(remaining) % 60
self._timer_label.configure(
text=f"Skip window: {minutes:02d}:{seconds:02d}",
)
else:
self._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."""
@ -697,94 +693,99 @@ def _pactl_path() -> str | None:
return shutil.which("pactl")
def _max_sink_volume() -> tuple[str, str, bool] | None:
"""Unmute the default sink, raise it to 100%, return state for restore.
Returns ``(sink_name, original_volume_pct, original_mute)`` or ``None``
when pactl is unavailable / the call fails. The alarm is loud only if the
user's actual sink (e.g. a Bluetooth speaker) is also turned up, so this
is the single biggest lever we have.
"""
pactl = _pactl_path()
if pactl is None:
_logger.warning("pactl not on PATH; cannot raise sink volume")
return None
def _alarm_sink_present(pactl: str) -> bool:
"""Return True when the dedicated alarm HDMI sink exists in PipeWire."""
try:
sink_proc = subprocess.run(
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,
)
sink = sink_proc.stdout.decode(errors="replace").strip()
if not sink:
return None
vol_proc = subprocess.run(
[pactl, "get-sink-volume", sink],
capture_output=True,
timeout=3,
check=False,
)
mute_proc = subprocess.run(
[pactl, "get-sink-mute", sink],
capture_output=True,
timeout=3,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
_logger.warning("pactl volume query failed", exc_info=True)
_logger.warning("pactl get-default-sink failed", exc_info=True)
return None
# "Volume: front-left: 20641 / 31% / ..." — grab the first percent token.
vol_text = vol_proc.stdout.decode(errors="replace")
pct = "100%"
for tok in vol_text.replace(",", " ").split():
if tok.endswith("%"):
pct = tok
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
muted = b"yes" in mute_proc.stdout
try:
subprocess.run(
[pactl, "set-sink-mute", sink, "0"],
capture_output=True,
timeout=3,
check=False,
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,
)
subprocess.run(
[pactl, "set-sink-volume", sink, "100%"],
capture_output=True,
timeout=3,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
_logger.warning("pactl volume set failed", exc_info=True)
return None
_logger.info("Raised sink %s volume %s\u2192100%%", sink, pct)
return (sink, pct, muted)
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_sink_volume(state: tuple[str, str, bool] | None) -> None:
"""Restore the sink volume + mute captured by :func:`_max_sink_volume`."""
if state is None:
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
sink, pct, muted = state
pactl = _pactl_path()
if pactl is None:
return
try:
subprocess.run(
[pactl, "set-sink-volume", sink, pct],
capture_output=True,
timeout=3,
check=False,
)
subprocess.run(
[pactl, "set-sink-mute", sink, "1" if muted else "0"],
capture_output=True,
timeout=3,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
_logger.warning("pactl volume restore failed", exc_info=True)
subprocess.run(
[pactl, "set-default-sink", old_default],
capture_output=True,
timeout=3,
check=False,
)
def _warn_if_no_real_sink() -> None:

View File

@ -38,6 +38,20 @@ 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.
ALARM_AUDIO_SINK_WAIT_SECONDS: float = 6.0
# Poll interval while waiting for the sink.
ALARM_AUDIO_SINK_POLL_SECONDS: float = 0.5
# 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 ->

View File

@ -1,18 +1,19 @@
#!/bin/bash
# systemd-sleep hook: restart wake-alarm.service after resume from hibernate.
# 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 wake-alarm.service is in a stopped state
# (it ran at login the previous evening and exited with Restart=no).
# This hook restarts it so the alarm fires on the correct alarm day.
# 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) — restarting wake-alarm.service for active sessions"
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.
@ -20,10 +21,10 @@ 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 wake-alarm.service for user $username (uid=$uid)"
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 wake-alarm.service 2>/dev/null \
|| logger -t wake-alarm-hook "Failed to start wake-alarm.service for $username (non-fatal)"
systemctl --user start morning-routine.service 2>/dev/null \
|| logger -t wake-alarm-hook "Failed to start morning-routine.service for $username (non-fatal)"
done < <(loginctl list-sessions --no-legend 2>/dev/null | awk '{print $2}' | sort -u)

View File

@ -14,22 +14,24 @@ if TYPE_CHECKING:
from collections.abc import Generator, Iterator
from python_pkg.wake_alarm._alarm import (
_activate_alarm_audio,
_alarm_sink_present,
_beep_loud,
_beep_medium,
_beep_pcspkr,
_beep_soft,
_current_default_sink,
_ensure_tone_wav,
_find_fan_hwmon,
_generate_code,
_is_alarm_day,
_max_fans,
_max_sink_volume,
_parse_args,
_play_on_extra_devices,
_play_tone,
_restore_alarm_audio,
_restore_display,
_restore_fans,
_restore_sink_volume,
_set_max_brightness,
_should_run_alarm,
_speaker_test_path,
@ -955,149 +957,173 @@ class TestWarnIfNoRealSink:
_warn_if_no_real_sink() # must not raise
class TestMaxSinkVolume:
"""Tests for _max_sink_volume and _restore_sink_volume."""
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._alarm.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._alarm.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._alarm.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._alarm.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._alarm.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._alarm.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, logs warning."""
with patch("python_pkg.wake_alarm._alarm.shutil.which", return_value=None):
assert _max_sink_volume() is None
def test_returns_none_when_default_sink_empty(self) -> None:
"""Empty get-default-sink output → returns None."""
sink_proc = MagicMock(stdout=b"")
with (
patch(
"python_pkg.wake_alarm._alarm.shutil.which",
return_value="/usr/bin/pactl",
),
patch(
"python_pkg.wake_alarm._alarm.subprocess.run",
return_value=sink_proc,
),
):
assert _max_sink_volume() is None
def test_query_failure_returns_none(self) -> None:
"""OSError during query → returns None, no raise."""
with (
patch(
"python_pkg.wake_alarm._alarm.shutil.which",
return_value="/usr/bin/pactl",
),
patch(
"python_pkg.wake_alarm._alarm.subprocess.run",
side_effect=OSError("boom"),
),
):
assert _max_sink_volume() is None
def test_set_failure_returns_none(self) -> None:
"""OSError during set-sink-volume → returns None."""
sink_proc = MagicMock(stdout=b"my_sink\n")
vol_proc = MagicMock(stdout=b"Volume: front-left: 20641 / 31% / -30.10 dB")
mute_proc = MagicMock(stdout=b"Mute: no\n")
def fake_run(cmd: list[str], **_kwargs: object) -> MagicMock:
if "get-default-sink" in cmd:
return sink_proc
if "get-sink-volume" in cmd:
return vol_proc
if "get-sink-mute" in cmd:
return mute_proc
raise subprocess.TimeoutExpired(cmd, 3)
with (
patch(
"python_pkg.wake_alarm._alarm.shutil.which",
return_value="/usr/bin/pactl",
),
patch(
"python_pkg.wake_alarm._alarm.subprocess.run",
side_effect=fake_run,
),
):
assert _max_sink_volume() is None
def test_happy_path_returns_state(self) -> None:
"""Successful query+set returns the captured state tuple."""
sink_proc = MagicMock(stdout=b"my_sink\n")
vol_proc = MagicMock(stdout=b"Volume: front-left: 20641 / 31% / -30.10 dB")
mute_proc = MagicMock(stdout=b"Mute: yes\n")
ok = MagicMock(stdout=b"", returncode=0)
def fake_run(cmd: list[str], **_kwargs: object) -> MagicMock:
if "get-default-sink" in cmd:
return sink_proc
if "get-sink-volume" in cmd:
return vol_proc
if "get-sink-mute" in cmd:
return mute_proc
return ok
with (
patch(
"python_pkg.wake_alarm._alarm.shutil.which",
return_value="/usr/bin/pactl",
),
patch(
"python_pkg.wake_alarm._alarm.subprocess.run",
side_effect=fake_run,
),
):
state = _max_sink_volume()
assert state == ("my_sink", "31%", True)
def test_happy_path_no_percent_token(self) -> None:
"""Missing % token → falls back to 100%, not None."""
sink_proc = MagicMock(stdout=b"s\n")
vol_proc = MagicMock(stdout=b"weird output")
mute_proc = MagicMock(stdout=b"Mute: no\n")
ok = MagicMock(stdout=b"", returncode=0)
def fake_run(cmd: list[str], **_kwargs: object) -> MagicMock:
if "get-default-sink" in cmd:
return sink_proc
if "get-sink-volume" in cmd:
return vol_proc
if "get-sink-mute" in cmd:
return mute_proc
return ok
with (
patch(
"python_pkg.wake_alarm._alarm.shutil.which",
return_value="/usr/bin/pactl",
),
patch(
"python_pkg.wake_alarm._alarm.subprocess.run",
side_effect=fake_run,
),
):
state = _max_sink_volume()
assert state == ("s", "100%", False)
class TestRestoreSinkVolume:
"""Tests for _restore_sink_volume."""
def test_none_state_is_noop(self) -> None:
"""None state → does nothing, no pactl call."""
with patch("python_pkg.wake_alarm._alarm.shutil.which") as mock_which:
_restore_sink_volume(None)
mock_which.assert_not_called()
def test_no_pactl_returns_silently(self) -> None:
"""State present but pactl missing → no raise, no call."""
"""No pactl on PATH → returns None without touching audio."""
with (
patch("python_pkg.wake_alarm._alarm.shutil.which", return_value=None),
patch("python_pkg.wake_alarm._alarm.subprocess.run") as mock_run,
):
_restore_sink_volume(("sink", "42%", False))
assert _activate_alarm_audio() is None
mock_run.assert_not_called()
def test_restores_volume_and_mute(self) -> None:
"""Calls set-sink-volume and set-sink-mute with captured values."""
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._alarm.shutil.which",
return_value="/usr/bin/pactl",
),
patch(
"python_pkg.wake_alarm._alarm._alarm_sink_present",
return_value=True,
),
patch(
"python_pkg.wake_alarm._alarm._current_default_sink",
return_value="jbl_sink",
),
patch("python_pkg.wake_alarm._alarm.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._alarm.shutil.which",
return_value="/usr/bin/pactl",
),
patch(
"python_pkg.wake_alarm._alarm._alarm_sink_present",
return_value=False,
),
patch("python_pkg.wake_alarm._alarm.time.sleep") as mock_sleep,
patch("python_pkg.wake_alarm._alarm.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._alarm.shutil.which",
return_value="/usr/bin/pactl",
),
patch(
"python_pkg.wake_alarm._alarm._alarm_sink_present",
side_effect=[False, True],
),
patch(
"python_pkg.wake_alarm._alarm._current_default_sink",
return_value="old",
),
patch("python_pkg.wake_alarm._alarm.time.sleep") as mock_sleep,
patch("python_pkg.wake_alarm._alarm.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._alarm.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._alarm.shutil.which", return_value=None),
patch("python_pkg.wake_alarm._alarm.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._alarm.shutil.which",
@ -1105,24 +1131,9 @@ class TestRestoreSinkVolume:
),
patch("python_pkg.wake_alarm._alarm.subprocess.run") as mock_run,
):
_restore_sink_volume(("sink", "42%", True))
_restore_alarm_audio("jbl_sink")
cmds = [call.args[0] for call in mock_run.call_args_list]
assert ["/usr/bin/pactl", "set-sink-volume", "sink", "42%"] in cmds
assert ["/usr/bin/pactl", "set-sink-mute", "sink", "1"] in cmds
def test_oserror_during_restore_is_swallowed(self) -> None:
"""OSError during restore → no raise."""
with (
patch(
"python_pkg.wake_alarm._alarm.shutil.which",
return_value="/usr/bin/pactl",
),
patch(
"python_pkg.wake_alarm._alarm.subprocess.run",
side_effect=OSError("boom"),
),
):
_restore_sink_volume(("sink", "50%", False)) # must not raise
assert ["/usr/bin/pactl", "set-default-sink", "jbl_sink"] in cmds
class TestParseArgs:

View File

@ -58,8 +58,8 @@ def _block_extra_devices() -> Generator[MagicMock]:
patch("python_pkg.wake_alarm._alarm._set_max_brightness"),
patch("python_pkg.wake_alarm._alarm._wake_display"),
patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"),
patch("python_pkg.wake_alarm._alarm._max_sink_volume", return_value=None),
patch("python_pkg.wake_alarm._alarm._restore_sink_volume"),
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"),
):
@ -160,39 +160,61 @@ class TestWakeAlarmDismiss:
assert alarm.dismissed is False
alarm._stop_beep.set()
def test_dismiss_window_expired(
def test_skip_window_expired_keeps_alarm_running(
self,
mock_tk_module: MagicMock,
) -> None:
"""Window expiry saves state with no skip."""
"""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_dismiss_window_expired()
alarm._on_skip_window_expired()
# Alarm stays active and audible; only the skip reward is gone.
assert alarm._skip_earnable is False
assert alarm._active is True
assert alarm.dismissed is False
mock_save.assert_called_once_with(
dismissed_at=None,
skip_workout=False,
)
assert not alarm._stop_beep.is_set()
mock_save.assert_not_called()
alarm._info_label.configure.assert_called()
alarm._stop_beep.set()
def test_dismiss_window_expired_noop_if_not_active(
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._skip_earnable is True
alarm._stop_beep.set()
def test_dismiss_after_skip_window_earns_no_skip(
self,
mock_tk_module: MagicMock,
) -> None:
"""Typing the code after the skip window stops the alarm w/o a skip."""
alarm = WakeAlarm(demo_mode=True)
alarm._skip_earnable = False
code = alarm._current_code
mock_entry = mock_tk_module.Entry.return_value
mock_entry.get.return_value = code
with patch(
"python_pkg.wake_alarm._alarm.save_wake_state",
) as mock_save:
alarm._on_dismiss_window_expired()
alarm._on_submit()
mock_save.assert_not_called()
assert alarm.dismissed is True
assert mock_save.call_args[1]["skip_workout"] is False
alarm._stop_beep.set()
@ -312,14 +334,15 @@ class TestBeepLoop:
alarm._stop_beep.set()
class TestCloseAndFallback:
"""Tests for close and fallback scheduling."""
class TestClose:
"""Tests for the alarm close path."""
def test_close_stops_beep_and_destroys(
self,
mock_tk_module: MagicMock,
) -> None:
"""_close sets stop event and destroys root."""
del mock_tk_module
alarm = WakeAlarm(demo_mode=True)
alarm._close()
assert alarm._stop_beep.is_set()
@ -330,32 +353,26 @@ class TestCloseAndFallback:
mock_tk_module: MagicMock,
) -> None:
"""_close calls _restore_fans with the saved fan state."""
del mock_tk_module
alarm = WakeAlarm(demo_mode=True)
alarm._fan_state = True
with patch("python_pkg.wake_alarm._alarm._restore_fans") as mock_restore:
alarm._close()
mock_restore.assert_called_once_with(active=True)
def test_close_and_schedule_fallback(
def test_close_restores_audio(
self,
mock_tk_module: MagicMock,
) -> None:
"""_close_and_schedule_fallback destroys root."""
"""_close restores the default sink captured at activation."""
del mock_tk_module
alarm = WakeAlarm(demo_mode=True)
alarm._close_and_schedule_fallback()
alarm.root.destroy.assert_called()
alarm._stop_beep.set()
def test_close_and_schedule_fallback_restores_fans(
self,
mock_tk_module: MagicMock,
) -> None:
"""_close_and_schedule_fallback calls _restore_fans with the saved state."""
alarm = WakeAlarm(demo_mode=True)
alarm._fan_state = True
with patch("python_pkg.wake_alarm._alarm._restore_fans") as mock_restore:
alarm._close_and_schedule_fallback()
mock_restore.assert_called_once_with(active=True)
alarm._audio_restore = "jbl_sink"
with patch(
"python_pkg.wake_alarm._alarm._restore_alarm_audio",
) as mock_restore:
alarm._close()
mock_restore.assert_called_once_with("jbl_sink")
alarm._stop_beep.set()
@ -442,25 +459,22 @@ class TestDismissWithoutSkip:
alarm._stop_beep.set()
class TestDismissWindowExpiredWidgets:
"""Tests for widget cleanup during dismiss window expiry."""
class TestSkipWindowExpiredMessage:
"""Tests for the on-screen message when the skip window expires."""
def test_expired_creates_label(
def test_expired_updates_status_label(
self,
mock_tk_module: MagicMock,
) -> None:
"""Expiry creates a 'Too late' label and destroys children."""
"""Expiry updates the status label instead of closing the alarm."""
del mock_tk_module
alarm = WakeAlarm(demo_mode=True)
mock_widget = MagicMock()
alarm._container.winfo_children.return_value = [mock_widget]
with patch(
"python_pkg.wake_alarm._alarm.save_wake_state",
):
alarm._on_dismiss_window_expired()
alarm._on_skip_window_expired()
mock_widget.destroy.assert_called_once()
mock_tk_module.Label.assert_called()
alarm._status_label.configure.assert_called_with(
text="No workout skip today.",
)
alarm._stop_beep.set()
@ -544,28 +558,45 @@ class TestRunMethod:
class TestUpdateTimerActive:
"""Tests for timer update when alarm is active."""
def test_update_timer_shows_remaining(
def test_update_timer_shows_skip_window(
self,
mock_tk_module: MagicMock,
) -> None:
"""Timer update shows remaining time when not dismissed."""
"""While the skip is earnable, the timer shows the skip-window count."""
del mock_tk_module
alarm = WakeAlarm(demo_mode=True)
alarm._update_timer()
alarm._timer_label.configure.assert_called()
text = alarm._timer_label.configure.call_args[1]["text"]
assert text.startswith("Skip window:")
alarm._stop_beep.set()
def test_update_timer_stops_at_zero(
def test_update_timer_shows_prompt_after_window(
self,
mock_tk_module: MagicMock,
) -> None:
"""Timer stops scheduling when remaining time reaches zero."""
"""After the window the timer shows the silence prompt and keeps going."""
import time as time_mod
alarm = WakeAlarm(demo_mode=True)
# Set alarm start far in the past so remaining = 0
# 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()
# root.after should NOT be called for re-scheduling
# (configure is still called to show 00:00)
alarm._timer_label.configure.assert_called()
text = alarm._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._timer_label.configure.reset_mock()
alarm._update_timer()
alarm._timer_label.configure.assert_not_called()
alarm._stop_beep.set()

View File

@ -4,6 +4,13 @@ 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

View File

@ -1,6 +1,6 @@
{
"date": "2026-05-23",
"dismissed_at": null,
"skip_workout": false,
"hmac": "17ae4173304d5f4b7c2376df2326162ae3bc4dfcf3a38ccb6f0647c81f61b5fa"
"date": "2026-05-25",
"dismissed_at": "2026-05-25T10:33:09.098156+00:00",
"skip_workout": true,
"hmac": "49ae99880405c6e3f0b4948b07d398980e223a91d33dc7d0c7f0f9254463fa92"
}