wake-alarm/DOCS-architecture.md
Krzysztof kuhy Rudnicki b3e8c237af 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
2026-08-27 20:04:01 +02:00

47 lines
2.2 KiB
Markdown

# 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.