Phase 3b: finish item 14 -- delete the fan script, clean up installed state

Completes the fan removal started in 9e0cd12, which took the Python out but
left the shell side behind.

- Delete wake-alarm-fans.sh.
- install.sh step 7 now REMOVES the retired script instead of installing it.
  An upgrade has to actively clean up: a machine that ran the old installer
  keeps a passwordless-root sudoers entry for a script that no longer exists,
  which is worse than the fan ramp ever was.

Verified on this machine rather than assumed -- both stale artifacts really
were present:

    /usr/local/bin/wake-alarm-fans.sh                        (existed)
    kuhy ALL=(root) NOPASSWD: /usr/local/bin/wake-alarm-fans.sh   (granted)

After the cleanup: script gone, /run state gone, the fan NOPASSWD line gone,
`visudo -c` parses, `sudo -n true` still works, and the rtcwake NOPASSWD entry
the alarm actually needs is untouched. The sed was rehearsed against a fake
sudoers file first, since getting it wrong locks the user out of sudo.

- AGENTS.md: drop the stale "ramps fans to 100%" description, and point the
  module map at the new DOCS-architecture.md (moved there to keep AGENTS.md
  under the 250-line cap).
- DOCS-architecture.md: where each behaviour now lives after the split, the
  HDMI-silence root cause, and the two timing invariants that must not
  collide -- both of which silently gut the alarm if broken.

404 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
This commit is contained in:
Krzysztof Rudnicki 2026-08-27 20:04:01 +02:00
parent 9e0cd123ff
commit b3e8c237af
4 changed files with 69 additions and 79 deletions

View File

@ -6,8 +6,7 @@ A wake alarm: on alarm days (`ALARM_DAYS` in `_constants.py` — currently
**every day**, `frozenset({0..6})`), the machine hibernates overnight and
wakes itself via `rtcwake` at the configured alarm time. `wake-alarm.service`
then opens a fullscreen Tk window that must be dismissed (with a typed challenge — see
`_challenges.py`), ramps fans to 100% via `wake-alarm-fans.sh`, plays audio
through whatever sink comes up after the monitor wakes, and optionally
`_challenges.py`), plays audio through every real sink, and optionally
toggles a TP-Link Tapo P110 smart plug via `python-kasa`.
## Scheduling — a per-minute timer, with hibernate as the fallback
@ -129,6 +128,13 @@ remote reader to keep in step.
Check what the PC currently resolves: `python -m wake_alarm._sync --show`.
Check what it would do right now: `python -m wake_alarm._trigger --dry-run`.
## Module map
`_alarm.py` owns the window and the dismiss flow and nothing else. The full
map of which module answers which question, the HDMI-silence root cause, and
the timing constants that must not collide are in **`DOCS-architecture.md`**.
Read it before changing the escalation or the failsafe.
## Cross-repo coupling — not a bug
`_constants.py`'s `WORKOUT_LOG_FILE` points at

46
DOCS-architecture.md Normal file
View File

@ -0,0 +1,46 @@
# wake_alarm architecture
Reference for the module layout after the 2026-08-26 split, and the two
failure modes that motivated it.
## Where behaviour lives
`_alarm.py` owns the window and the dismiss flow and nothing else. When looking
for behaviour, start here:
| Question | Module |
|---|---|
| Should the alarm run at all today? | `_gating` (no time gate — that is `_trigger`) |
| What should it sound like right now? | `_escalation` (pure), driven by `_beep_loop` |
| Why did it go quiet? | `_escalation` — the failsafe at `FAILSAFE_SILENCE_SECONDS` |
| Why is there no sound on HDMI? | `_sinks` + `_reactivate` (see below) |
| Challenge presentation / rounds | `_rounds`, `_countdown`, `_dismiss_view` |
| Recurring timers | `_alarm_timers` (a mixin; they reschedule via `root.after`) |
| Screen flash | `_screen_flash` (~1.3 Hz, deliberately under the 3 Hz threshold) |
| Startup hardware | `_startup` (display, brightness, plug + pre-roll) |
| Structured logs | `_logging_setup`, `_log_sink`, `_log_dedupe`, `_correlation` |
### The audio failure this fixed
`_activate_all_outputs()` ran **once**, at startup, and `_parse_profile_line`
drops any profile reporting `available: no`. At wake time the monitor has not
finished its HDMI handshake, so the profile is skipped — and while a card's
profile is `off` its sink **does not exist**, so `_play_on_all_sinks`'s
per-cycle re-enumeration can never find it. `_reactivate` re-runs activation on
a decaying schedule and logs the enumerated sink list each pass.
A **powered-off speaker is not detectable from software** — the analog sink is
present and `IDLE` either way. That is a hardware problem; see
`DOCS-speaker-recommendation.md`.
### Timing constants that must not collide
`FAILSAFE_SILENCE_SECONDS` (300) silences **audio only**; the lock, challenge
and workout gate persist. Two invariants are locked in by tests in
`test_escalation.py`, because breaking either silently guts the alarm:
- `PHASE_MEDIUM_END * 60 < FAILSAFE_SILENCE_SECONDS` — otherwise the escalation
never leaves its softest rung before going quiet.
- `ESCAPE_OFFER_AFTER_SECONDS` (900) `> FAILSAFE_SILENCE_SECONDS` — otherwise
the escape hatch appears exactly as the alarm stops being annoying.

View File

@ -11,7 +11,7 @@
# 4. Installs the systemd-sleep hook (restarts alarm after hibernate resume)
# 5. Adds a sudoers entry for passwordless rtcwake
# 6. Installs shutdown wrapper so "shutdown now" also hibernates on alarm nights
# 7. Installs fan-control script so alarm can max fans on wake
# 7. Removes the retired fan-control script + its sudoers entry
# 8. Installs python-kasa (AUR) so the alarm can toggle a Tapo P110 smart plug
# 9. Installs ddcutil and grants /dev/i2c-* access for DDC/CI monitor control
@ -40,7 +40,6 @@ TRIGGER_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-trigger.service"
TRIGGER_TIMER_SRC="$SCRIPT_DIR/wake-alarm-trigger.timer"
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"
@ -112,19 +111,21 @@ sudo chmod 0755 "$SHUTDOWN_WRAPPER_DST"
echo " Installed to $SHUTDOWN_WRAPPER_DST"
echo " 'shutdown now' will now hibernate (not poweroff) on alarm nights."
# 7. Install fan-control script and its sudoers entry
echo "[7/9] 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"
# 7. Remove the retired fan-control script and its sudoers entry.
# The alarm no longer ramps fans: it added anxiety without helping anyone wake
# up. An upgrade must actively clean up, or a machine that installed the old
# version keeps a passwordless root entry for a script that no longer exists.
echo "[7/9] Removing retired fan-control script..."
if [[ -e "$FANS_SCRIPT_DST" ]]; then
sudo rm -f "$FANS_SCRIPT_DST"
echo " Removed $FANS_SCRIPT_DST"
fi
if [[ -f "$SUDOERS_FILE" ]] && grep -qF "$FANS_SCRIPT_DST" "$SUDOERS_FILE"; then
sudo sed -i "\\|$FANS_SCRIPT_DST|d" "$SUDOERS_FILE"
sudo chmod 0440 "$SUDOERS_FILE"
echo " Dropped the fan NOPASSWD sudoers entry"
fi
sudo rm -f /run/wake-alarm-fans.state
# 8. Install python-kasa (AUR) for TP-Link Tapo P110 smart-plug control
echo "[8/9] Installing python-kasa (AUR)..."

View File

@ -1,63 +0,0 @@
#!/bin/bash
# Control ALL NCT pwm fan channels for the wake alarm.
#
# Usage:
# wake-alarm-fans.sh max — ramp every pwm[1-9] channel to 100%
# wake-alarm-fans.sh restore — restore the state captured by the last `max`
#
# Must be run as root (installed in /etc/sudoers.d/wake-alarm via install.sh).
# Safe: fans are designed to run at max speed indefinitely.
#
# State is stored at $STATE_FILE so `restore` doesn't need any arguments.
set -euo pipefail
STATE_FILE="/run/wake-alarm-fans.state"
# Locate the hwmon directory for any NCT Super I/O fan controller.
HWMON=""
for name_file in /sys/class/hwmon/hwmon*/name; do
[[ -f "$name_file" ]] || continue
chip=$(cat "$name_file")
case "$chip" in
nct6775|nct6779|nct6791|nct6792|nct6793|nct6795|nct6796|nct6797|nct6798|nct6799)
HWMON=$(dirname "$name_file")
break
;;
esac
done
if [[ -z "$HWMON" ]]; then
# Not an error — hardware without this chip just skips fan control.
exit 0
fi
case "${1:-}" in
max)
: > "$STATE_FILE"
for pwm in "$HWMON"/pwm[0-9]; do
[[ -w "$pwm" ]] || continue
enable="${pwm}_enable"
[[ -w "$enable" ]] || continue
old_pwm=$(cat "$pwm")
old_enable=$(cat "$enable")
printf '%s %s %s\n' "$pwm" "$old_enable" "$old_pwm" >> "$STATE_FILE"
echo 1 > "$enable" # Switch to manual mode.
echo 255 > "$pwm" # 255/255 = 100% speed.
done
;;
restore)
[[ -f "$STATE_FILE" ]] || exit 0
while read -r pwm old_enable old_pwm; do
[[ -w "$pwm" && -w "${pwm}_enable" ]] || continue
# Restore pwm value first, then restore the control mode.
echo "$old_pwm" > "$pwm"
echo "$old_enable" > "${pwm}_enable"
done < "$STATE_FILE"
rm -f "$STATE_FILE"
;;
*)
echo "Usage: $0 max | $0 restore" >&2
exit 1
;;
esac