diff --git a/AGENTS.md b/AGENTS.md index 322683a..4144656 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/DOCS-architecture.md b/DOCS-architecture.md new file mode 100644 index 0000000..0447e56 --- /dev/null +++ b/DOCS-architecture.md @@ -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. + diff --git a/install.sh b/install.sh index 7dba087..abc903e 100755 --- a/install.sh +++ b/install.sh @@ -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)..." diff --git a/wake-alarm-fans.sh b/wake-alarm-fans.sh deleted file mode 100755 index 8f014da..0000000 --- a/wake-alarm-fans.sh +++ /dev/null @@ -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