mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 12:27:32 +01:00
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:
parent
9e0cd123ff
commit
b3e8c237af
10
AGENTS.md
10
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
|
||||
|
||||
46
DOCS-architecture.md
Normal file
46
DOCS-architecture.md
Normal 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.
|
||||
|
||||
29
install.sh
29
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)..."
|
||||
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user