From e67cef0c54ef7812f4501ba553f669af040823d1 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 30 Aug 2026 15:24:02 +0200 Subject: [PATCH] Retire the PC's waking role; keep the trigger The PC no longer wakes anyone -- the phone does. This deletes the alarm window, the audio escalation, the sink/reactivation machinery, the power-key inhibitor, the escape hatch, the curtains, the smart plug, the brightness and DDC/CI control, the hibernate path (shutdown wrapper + rtcwake + sleep hook) and the challenge set: 30 modules, 34 test files, ~5k lines. What stays is the part that still earns its keep: the per-minute trigger reads the alarm the phone published and starts morning-routine.service, so the workout lock still runs at the right time. THE ONE LOAD-BEARING BEHAVIOUR CHANGE. Deleting _alarm.py orphaned the only writer of wake_state.json, and _trigger gated on it via was_alarm_dismissed_today(). With that gone -- and was_workout_logged_today() already permanently False, since WORKOUT_LOG_FILE does not exist -- both gates would have been dead and the routine restarted on every tick for the rest of the 60-minute catch-up window. The unit is Type=simple, so a start while it runs is a no-op, but the moment the workout lock finished the next tick would relaunch it on top of the user. So _trigger now writes its own once-per-day latch (save_trigger_fired / was_trigger_fired_today) and gates on that. Collapsing the catch-up window instead was rejected: that window is the entire reason a suspended or just-booted machine still fires. It also replaces the deleted sleep hook, via Persistent=true. _trigger also imported should_refire from _alarm_session, which was on the delete list. Since start_session lived only in _alarm.py, no session record is ever written again and the branch was unreachable, not merely unused. WAKE_STATE_FILE is now redirected in tests/conftest.py rather than per-file. The trigger tests never redirected it, because until this commit nothing on that path wrote anything -- and a per-file redirect is exactly the kind that gets forgotten when a new writer appears, with a silent failure: a green suite that quietly stamped the live state file. Verified by mtime that it did not. Cross-repo, same change: morning_routine._orchestrator loses its alarm leg and its --with-alarm flag. Leaving an orchestrator pointing at a deleted module caused a month-long silent failure once already. uninstall.sh is new, and matters more than it looks. /usr/local/bin/shutdown is root-owned and shadows /usr/bin/shutdown; deleting this repo would leave it in place, and with the alarm cache gone it falls through to rtcwake + hibernate -- so "shutdown now" would silently stop being a poweroff, every day, forever. /etc/sudoers.d/wake-alarm is deliberately NOT removed: screen-locker's own _wake_alarm.py still needs passwordless rtcwake. 159 tests pass at 100% branch coverage; all 26 pre-commit hooks green. Verified by running it: _sync --show and _trigger --dry-run both resolve. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN --- .pre-commit-config.yaml | 8 - AGENTS.md | 345 ++++++++---------- DOCS-architecture.md | 45 --- DOCS-curtains-hardware.md | 85 ----- DOCS-speaker-recommendation.md | 100 ----- DOCS-vmbox.md | 39 +- README.md | 78 ++-- TODO-hardening-continue.md | 16 +- install.sh | 176 ++------- pyproject.toml | 15 +- requirements.txt | 9 +- scripts/verify_screen_fits.py | 177 --------- scripts/vmbox/check_inhibitor.py | 72 ---- scripts/vmbox/check_refire.py | 38 -- scripts/vmbox/guest_cases.sh | 113 +----- scripts/vmbox/harness.sh | 36 +- shutdown-wrapper.sh | 113 ------ sleep-hook.sh | 30 -- uninstall.sh | 100 +++++ wake-alarm.service | 31 -- wake_alarm/_alarm.py | 249 ------------- wake_alarm/_alarm_display.py | 76 ---- wake_alarm/_alarm_flash.py | 71 ---- wake_alarm/_alarm_session.py | 244 ------------- wake_alarm/_alarm_state.py | 42 --- wake_alarm/_alarm_timers.py | 111 ------ wake_alarm/_alarm_view.py | 207 ----------- wake_alarm/_audio.py | 250 ------------- wake_alarm/_beep_loop.py | 99 ----- wake_alarm/_brightness.py | 48 --- wake_alarm/_challenges.py | 216 ----------- wake_alarm/_constants.py | 145 +------- wake_alarm/_correlation.py | 67 ---- wake_alarm/_countdown.py | 92 ----- wake_alarm/_curtains.py | 166 --------- wake_alarm/_dismiss_view.py | 58 --- wake_alarm/_escalation.py | 82 ----- wake_alarm/_escape_dialog.py | 201 ---------- wake_alarm/_escape_flow.py | 143 -------- wake_alarm/_gating.py | 81 ---- wake_alarm/_inhibit.py | 215 ----------- wake_alarm/_lock_setup.py | 55 --- wake_alarm/_reactivate.py | 180 --------- wake_alarm/_rounds.py | 120 ------ wake_alarm/_screen_flash.py | 42 --- wake_alarm/_sink_parsing.py | 114 ------ wake_alarm/_sinks.py | 249 ------------- wake_alarm/_smart_plug.py | 155 -------- wake_alarm/_startup.py | 52 --- wake_alarm/_state.py | 72 +--- wake_alarm/_tone.py | 48 --- wake_alarm/_trigger.py | 29 +- wake_alarm/_view_proxies.py | 147 -------- wake_alarm/tests/conftest.py | 118 +----- wake_alarm/tests/test_alarm.py | 230 ------------ wake_alarm/tests/test_alarm_audio.py | 234 ------------ wake_alarm/tests/test_alarm_challenges.py | 245 ------------- wake_alarm/tests/test_alarm_dismiss.py | 232 ------------ wake_alarm/tests/test_alarm_display.py | 146 -------- wake_alarm/tests/test_alarm_fanout.py | 139 ------- .../tests/test_alarm_flash_challenge.py | 180 --------- wake_alarm/tests/test_alarm_main.py | 149 -------- wake_alarm/tests/test_alarm_part2.py | 170 --------- wake_alarm/tests/test_alarm_part3.py | 191 ---------- wake_alarm/tests/test_alarm_part4.py | 243 ------------ wake_alarm/tests/test_alarm_playback.py | 136 ------- wake_alarm/tests/test_alarm_session.py | 150 -------- wake_alarm/tests/test_alarm_session_refire.py | 134 ------- wake_alarm/tests/test_alarm_sinks.py | 243 ------------ wake_alarm/tests/test_alarm_timers_flash.py | 192 ---------- wake_alarm/tests/test_beep_loop.py | 151 -------- wake_alarm/tests/test_brightness.py | 118 ------ wake_alarm/tests/test_correlation.py | 69 ---- wake_alarm/tests/test_curtains.py | 118 ------ wake_alarm/tests/test_escalation.py | 91 ----- wake_alarm/tests/test_escape_flow.py | 230 ------------ wake_alarm/tests/test_escape_integration.py | 229 ------------ wake_alarm/tests/test_gating.py | 75 ---- wake_alarm/tests/test_inhibit.py | 176 --------- wake_alarm/tests/test_inhibit_cleanup.py | 103 ------ wake_alarm/tests/test_pcspkr.py | 40 -- wake_alarm/tests/test_reactivate.py | 213 ----------- wake_alarm/tests/test_sinks_activate.py | 158 -------- wake_alarm/tests/test_sinks_restore.py | 109 ------ wake_alarm/tests/test_sinks_warn.py | 83 ----- wake_alarm/tests/test_smart_plug.py | 166 --------- wake_alarm/tests/test_smart_plug_ops.py | 234 ------------ wake_alarm/tests/test_state.py | 29 +- wake_alarm/tests/test_state_queries.py | 124 +------ wake_alarm/tests/test_tone.py | 53 --- wake_alarm/tests/test_trigger.py | 96 +---- wake_alarm/tests/test_trigger_clamp.py | 6 +- wake_alarm/tests/test_trigger_main.py | 43 ++- 93 files changed, 492 insertions(+), 11106 deletions(-) delete mode 100644 DOCS-architecture.md delete mode 100644 DOCS-curtains-hardware.md delete mode 100644 DOCS-speaker-recommendation.md delete mode 100755 scripts/verify_screen_fits.py delete mode 100644 scripts/vmbox/check_inhibitor.py delete mode 100644 scripts/vmbox/check_refire.py delete mode 100755 shutdown-wrapper.sh delete mode 100755 sleep-hook.sh create mode 100755 uninstall.sh delete mode 100644 wake-alarm.service delete mode 100644 wake_alarm/_alarm.py delete mode 100644 wake_alarm/_alarm_display.py delete mode 100644 wake_alarm/_alarm_flash.py delete mode 100644 wake_alarm/_alarm_session.py delete mode 100644 wake_alarm/_alarm_state.py delete mode 100644 wake_alarm/_alarm_timers.py delete mode 100644 wake_alarm/_alarm_view.py delete mode 100644 wake_alarm/_audio.py delete mode 100644 wake_alarm/_beep_loop.py delete mode 100644 wake_alarm/_brightness.py delete mode 100644 wake_alarm/_challenges.py delete mode 100644 wake_alarm/_correlation.py delete mode 100644 wake_alarm/_countdown.py delete mode 100644 wake_alarm/_curtains.py delete mode 100644 wake_alarm/_dismiss_view.py delete mode 100644 wake_alarm/_escalation.py delete mode 100644 wake_alarm/_escape_dialog.py delete mode 100644 wake_alarm/_escape_flow.py delete mode 100644 wake_alarm/_gating.py delete mode 100644 wake_alarm/_inhibit.py delete mode 100644 wake_alarm/_lock_setup.py delete mode 100644 wake_alarm/_reactivate.py delete mode 100644 wake_alarm/_rounds.py delete mode 100644 wake_alarm/_screen_flash.py delete mode 100644 wake_alarm/_sink_parsing.py delete mode 100644 wake_alarm/_sinks.py delete mode 100644 wake_alarm/_smart_plug.py delete mode 100644 wake_alarm/_startup.py delete mode 100644 wake_alarm/_tone.py delete mode 100644 wake_alarm/_view_proxies.py delete mode 100644 wake_alarm/tests/test_alarm.py delete mode 100644 wake_alarm/tests/test_alarm_audio.py delete mode 100644 wake_alarm/tests/test_alarm_challenges.py delete mode 100644 wake_alarm/tests/test_alarm_dismiss.py delete mode 100644 wake_alarm/tests/test_alarm_display.py delete mode 100644 wake_alarm/tests/test_alarm_fanout.py delete mode 100644 wake_alarm/tests/test_alarm_flash_challenge.py delete mode 100644 wake_alarm/tests/test_alarm_main.py delete mode 100644 wake_alarm/tests/test_alarm_part2.py delete mode 100644 wake_alarm/tests/test_alarm_part3.py delete mode 100644 wake_alarm/tests/test_alarm_part4.py delete mode 100644 wake_alarm/tests/test_alarm_playback.py delete mode 100644 wake_alarm/tests/test_alarm_session.py delete mode 100644 wake_alarm/tests/test_alarm_session_refire.py delete mode 100644 wake_alarm/tests/test_alarm_sinks.py delete mode 100644 wake_alarm/tests/test_alarm_timers_flash.py delete mode 100644 wake_alarm/tests/test_beep_loop.py delete mode 100644 wake_alarm/tests/test_brightness.py delete mode 100644 wake_alarm/tests/test_correlation.py delete mode 100644 wake_alarm/tests/test_curtains.py delete mode 100644 wake_alarm/tests/test_escalation.py delete mode 100644 wake_alarm/tests/test_escape_flow.py delete mode 100644 wake_alarm/tests/test_escape_integration.py delete mode 100644 wake_alarm/tests/test_gating.py delete mode 100644 wake_alarm/tests/test_inhibit.py delete mode 100644 wake_alarm/tests/test_inhibit_cleanup.py delete mode 100644 wake_alarm/tests/test_pcspkr.py delete mode 100644 wake_alarm/tests/test_reactivate.py delete mode 100644 wake_alarm/tests/test_sinks_activate.py delete mode 100644 wake_alarm/tests/test_sinks_restore.py delete mode 100644 wake_alarm/tests/test_sinks_warn.py delete mode 100644 wake_alarm/tests/test_smart_plug.py delete mode 100644 wake_alarm/tests/test_smart_plug_ops.py delete mode 100644 wake_alarm/tests/test_tone.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 25ba5be..472c207 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -95,7 +95,6 @@ repos: - --jobs=4 additional_dependencies: - pytest - - python-kasa - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.1#subdirectory=gatelock # Test suites intentionally use patterns (protected-access, magic-value # comparisons) that don't belong in the source-code 10/10 gate. @@ -147,13 +146,6 @@ repos: entry: bash -c 'printf "%s\0" "$@" | xargs -0 -n 40 shellcheck --severity=warning' -- language: system types: [shell] - - id: screens-fit - name: Every lock screen fits 1366x768 (no scrolling required) - entry: python3 -m scripts.verify_screen_fits - language: system - files: '^wake_alarm/.*\.py$' - pass_filenames: false - require_serial: true # The shared 250-line cap. This repo used to run a local script with a # higher limit under this same hook id, so it looked gated while letting # through files every other repo rejects. diff --git a/AGENTS.md b/AGENTS.md index 6805219..e8ae6a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,249 +2,208 @@ ## What this does -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`), plays audio through every real sink, and optionally -toggles a TP-Link Tapo P110 smart plug via `python-kasa`. +**The phone is the alarm.** `phone_app/` arms and rings it and gates the +dismissal; this repo's Python half only reads the alarm time the phone +published and starts the morning routine at that time. -## Scheduling — a per-minute timer, with hibernate as the fallback +The PC's waking role was deleted on 2026-08-30: no alarm window, no audio +escalation, no smart plug, no curtains, no `rtcwake`, no `shutdown` wrapper, +no sleep hook. Making the PC wake someone reliably cost more than it returned. +Everything about it is in git history; do not reintroduce a piece of it +without saying why the phone cannot do that job. -The PC is **always on**. The alarm time comes from the phone, which can change -it at any hour, so nothing about the schedule may be baked in ahead of time. +## The dismiss sequence (phone) -**Primary path — `wake-alarm-trigger.timer`:** +``` +tag 1 -> 25 steps + 2 min -> tag 2 -> 3 challenges +``` -1. The timer ticks every minute (`OnCalendar=*:*:00`, `Persistent=true`) and - runs `python -m wake_alarm._trigger` — a short, stateless check-and-fire. -2. `_trigger` resolves the alarm via `_sync.get_alarm()`, builds today's fire - instant in **local** time, and fires iff `now` is in - `[fire_time, fire_time + TRIGGER_CATCHUP_MINUTES)`. The catch-up window is - what makes a suspended or just-booted machine still fire. -3. Firing = `systemctl --user start morning-routine.service`. **Never** - `wake-alarm.service` directly — see the orchestrator section below. - Starting an already-active unit is a no-op, so an alarm on screen needs no - extra dedupe state; the `was_alarm_dismissed_today()` / - `was_workout_logged_today()` gates cover the rest. +Owned by `phone_app/lib/services/dismiss_gate.dart`. **Strict order, and every +stage latches** — an unknown or out-of-order tag is ignored, never a reset. +`room_proof.dart` answers only "did this one check pass just now"; the +ordering lives in the gate. Keep it that way: the previous version smeared the +decision across `setState` calls and had a real bug where a wrong tap after +passing sent the user back to the start. -A timer, not a resident daemon: each tick is a fresh process, so there is no -long-lived scheduler to die silently and no wall-clock jump to handle across -suspend/resume. +- **Two *distinct* tags.** The same id in both slots is refused at + registration — one tag satisfying both stages removes the walk, which is the + entire point. Stored as a JSON list under `roomProof.nfcTagIds`; a legacy + single `roomProof.nfcTagId` migrates into slot 1. +- **Steps are unchanged** (`requiredSteps = 25`, `requiredElapsed = 2 min`), + and are counted from ring start, *not* re-baselined at tag 1. Consequence: + walking to tag 1 already banks the steps, so only the elapsed floor really + binds — which is why the steps stage auto-polls instead of offering a button. +- **Answers are cumulative, not consecutive.** A wrong one costs the attempt + and nothing else. +- **The bypass skips the tags, never the walk.** After + `DismissGate.bypassAfter` (5 min), or immediately when NFC is off or fewer + than two distinct tags are registered, the tag stages are skipped and the + count rises to 6. Jumping straight to the challenges would let someone lie in + bed for five minutes and answer questions without standing up — weaker than + the gate this replaced. Do not "simplify" it into that. +- **No challenge may hide its prompt.** The memorise-then-hide kind was + deleted, not merely unrouted: the ring screen is reached by walking in from + another room. -**Fallback path — hibernate (only when the PC really is powered down):** +`RingScreen` takes an injectable `clock`. `Stopwatch` reads the wall clock, +which `tester.pump()` does not advance, so without that seam no widget test +can reach the two-minute floor or the five-minute bypass. -`shutdown-wrapper.sh` (installed to `/usr/local/bin/shutdown`, shadowing -`/usr/bin/shutdown` via PATH order) still intercepts shutdown/poweroff, reads -the same synced alarm, and sets `rtcwake`. `sleep-hook.sh` (in -`/usr/lib/systemd/system-sleep/`) fires on resume (`$1 == post`) and starts -`morning-routine.service`. This is now a backstop, not the main mechanism. +More, including everything that is a hard crash or a silent no-ring: +**`DOCS-phone-alarm.md`**. -If you change `WAKE_AFTER_HOURS` in `_constants.py`, you must also update the -duplicate constant in `shutdown-wrapper.sh` (`WAKE_AFTER_HOURS=8`) — they are -not wired together, by design (the shell wrapper has no Python runtime -available at the point it intercepts `shutdown`). +## Scheduling — a per-minute timer -**The wrapper no longer talks to the network or handles a token.** It reads -`~/.config/wake_alarm/alarm_cache.json` — the cache `_sync.py` already -refreshes every minute — and falls back to `WAKE_AFTER_HOURS` if that file is -absent, malformed, or more than 2 days old. This removed the duplicated token -search order (`read_sync_token()` used to mirror `SYNC_CONFIG_FILE` / -`SYNC_TOKEN_FALLBACK_FILES`), and it is what makes the wrapper survive the -move to Firebase unchanged: an ID token expires in about an hour, and a shell -script at shutdown time has no safe way to refresh one. The only coupling left -is the cache's `{"hour","minute","fetched_at"}` shape — keep the reader in the -wrapper in step with `_write_cache()` in `_sync.py`. +`wake-alarm-trigger.timer` ticks every minute (`OnCalendar=*:*:00`, +`Persistent=true`) and runs `python -m wake_alarm._trigger` — a short, +stateless check-and-fire. It resolves the alarm via `_sync.get_alarm()`, builds +today's fire instant in **local** time, and fires iff `now` is in +`[fire_time, fire_time + TRIGGER_CATCHUP_MINUTES)`. Firing means +`systemctl --user start morning-routine.service`, which runs the workout lock. -## `wake-alarm.service` must never auto-start +**The once-per-day latch is load-bearing.** After a successful start, +`_trigger` writes `save_trigger_fired()` and `_skip_reason` gates on +`was_trigger_fired_today()`. Without it the routine restarts on every tick for +the rest of the 60-minute window: the unit is `Type=simple`, so a start while +it runs is a no-op, but the moment the workout lock finishes the next tick +relaunches it on top of the user. This latch replaced the alarm's own +dismissal record when `_alarm.py` was deleted — deleting one without adding the +other is a silent regression, and that is exactly the trap. -`wake-alarm.service` deliberately has **no `[Install]` section** and nothing -starts it automatically. `_should_run_alarm()` gates on day / dismissed / -workout-logged but has **no time gate**, so a -`WantedBy=graphical-session.target` there fires the alarm at *login* time on -any alarm day — which is every day now that the machine no longer reboots into -the alarm. `install.sh` explicitly runs `systemctl --user disable -wake-alarm.service` to clear the stale symlink from older installs. - -The unit survives only as a manual test handle. Do not "fix" its missing -auto-start. +`Persistent=true` plus the 60-minute window is also what replaced the deleted +systemd-sleep hook: a suspended or just-booted machine still fires. ## Reading the phone's alarm -`_sync.py` is the only reader of the remote alarm. `shutdown-wrapper.sh` reads -`_sync.py`'s local cache instead of the remote file, so there is no second -remote reader to keep in step. +`_sync.py` is the only reader. - Source of truth: a `crdt_sync` log written by `phone_app/` under - `wake-alarm-sync/devices//alarm.json`. The value lives at - `.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`. **The same logical path - on both backends** — Firebase's namespace mirrors the repo layout — so only - the transport differs, never the parse. + `wake-alarm-sync/devices//alarm.json`, at + `.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`. Same logical path on both + backends, so only the transport differs, never the parse. - **The Firebase reader discovers device directories; it is not pinned to `devices/phone/`.** The phone's device id is migrating from the fixed - constant `phone` to a per-install uuid, and a reader pinned to the old path - would silently stop seeing the alarm the moment the phone migrated — then - fall back to `FALLBACK_ALARM_HOUR`, i.e. wake at the wrong time. So - `_fetch_firebase_alarm()` lists `SYNC_DEVICES_PREFIX`, reads every device's - `alarm.json`, and picks the newest by the hour field's **HLC** (not by - listing order), with `device_id` breaking exact ties so two devices stamped - in the same millisecond don't alternate between ticks. A device whose - payload doesn't parse is skipped rather than allowed to win. - `SYNC_ALARM_PATH` remains as a last-resort read for a backend that lists - nothing — keep it until every install has migrated. The **GitHub** fallback - still reads the single fixed path; it is the mirror, not the primary. -- Payload parsing/ranking lives in `_alarm_payload.py`, split out of `_sync.py` - so the wire format is testable without network or disk (and to keep both - files under the 500-line cap). `_sync.py` owns fetching, caching and - fallback; `_alarm_payload.py` owns "what did the phone actually say". -- **Firebase first, GitHub as the cutover mirror** (`fetch_remote_alarm()` is - just `_fetch_firebase_alarm() or _fetch_github_alarm()`). Retiring the - mirror is deleting the GitHub branch plus `SYNC_API_BASE`/`SYNC_REPO_*`. -- Firebase config: the shared `~/.config/crdt-sync/firebase.json`; the - refresh token is cached per app under `SYNC_APP_NAME` (`wake_alarm`), so - this app's credential is separate from its siblings'. The config file is - checked **before** anything is constructed, so an unconfigured machine + constant `phone` to a per-install uuid, and a pinned reader would silently + stop seeing the alarm and fall back to `FALLBACK_ALARM_HOUR` — i.e. the wrong + time. `_fetch_firebase_alarm()` lists `SYNC_DEVICES_PREFIX`, reads every + device's `alarm.json`, and picks the newest by the hour field's **HLC**, with + `device_id` breaking exact ties. A payload that does not parse is skipped + rather than allowed to win. `SYNC_ALARM_PATH` remains as a last-resort read + for a backend that lists nothing. +- Payload parsing/ranking lives in `_alarm_payload.py`: `_sync.py` owns + fetching, caching and fallback; `_alarm_payload.py` owns "what did the phone + actually say". +- **Firebase first, GitHub as the cutover mirror.** Retiring the mirror is + deleting the GitHub branch plus `SYNC_API_BASE`/`SYNC_REPO_*`. +- Firebase config: shared `~/.config/crdt-sync/firebase.json`; the refresh + token is cached per app under `SYNC_APP_NAME` (`wake_alarm`). The config file + is checked **before** anything is constructed, so an unconfigured machine never touches the network. -- GitHub token (fallback path only): `~/.config/wake_alarm/sync.json` key - `token`, else a sibling app's raw - `~/.config/{diet_guard,screen_locker}/sync_token`. Needs `repo` scope — the - repo is private, and a token without access returns **404, not 403**. +- GitHub token (fallback only): `~/.config/wake_alarm/sync.json` key `token`, + else a sibling app's raw `~/.config/{diet_guard,screen_locker}/sync_token`. + Needs `repo` scope — the repo is private, so a token without access returns + **404, not 403**. - `crdt_sync` is imported at module scope but **guarded by - `try/except ImportError`**, exactly like `python-kasa` in `_smart_plug.py`: - the service runs `/usr/bin/python`, not a venv, so a dependency that failed - to install must degrade the read to the GitHub path rather than crash the - per-minute trigger. The GitHub path stays stdlib-only for that reason. - Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the dev venv. -- Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch → - stale cache → `FALLBACK_ALARM_HOUR`. The cache - (`~/.config/wake_alarm/alarm_cache.json`) is what bounds remote reads to - ~288/day despite the per-minute tick, and what keeps a network blip from - costing a wake-up. + `try/except ImportError`**: the service runs `/usr/bin/python`, not a venv, so + a dependency that failed to install must degrade the read to the GitHub path + rather than crash the per-minute trigger. The GitHub path stays stdlib-only + for that reason. Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the + dev venv. +- Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch → stale + cache → `FALLBACK_ALARM_HOUR`. The cache bounds remote reads to ~288/day + despite the per-minute tick. - **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute, - so a persistent condition logged loudly is 1440 identical journal lines a - day. `_trigger` emits the single WARNING that matters: it is firing *now* on - a non-phone alarm time. - -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`. - -## The phone rings on its own - -`phone_app/` is no longer a time picker: it arms and rings its own alarm -(items 1/4) via five Kotlin files behind **one** MethodChannel, gating -dismissal behind a room proof plus a challenge. **Never replace that with a -pub plugin, and never move the ring off `STREAM_ALARM`.** Everything that is -a hard crash or a silent no-ring is in **`DOCS-phone-alarm.md`**. - -## 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 -`~/screen-locker/screen_locker/workout_log.json` — a file owned by the -separate, already-standalone `screen-locker` repo -(https://github.com/kuhyx/screen-locker). This is intentional: the alarm -reads whether today's workout was already logged by screen-locker to decide -whether the morning routine should also lock the workout screen. If this -path ever raises `ModuleNotFoundError`-style confusion, the bug is almost -certainly in the **orchestrator** (`morning_routine` in testsAndMisc), not -here — see the next section. + so a persistent condition logged loudly is 1440 identical journal lines a day. + `_trigger` emits the single WARNING that matters: it is firing *now* on a + non-phone alarm time. ## The morning_routine orchestrator lives elsewhere `morning_routine._orchestrator` (in `testsAndMisc/python_pkg/morning_routine/`) -runs this package and `screen_locker` as two sequential subprocesses. When -either package is extracted to its own repo, **the orchestrator's module -reference must be updated in the same change** — this exact mistake -(orchestrator left pointing at `python_pkg.screen_locker.screen_lock` after -screen-locker's extraction on 2026-05-28) caused a month-long silent -production failure where the alarm fired and dismissed correctly but the -workout lock crashed with `ModuleNotFoundError` on every run. Once both -`wake_alarm` and `screen_locker` are pip-installed system-wide, the -orchestrator needs no `PYTHONPATH`/`cwd` plumbing — plain -`subprocess.run([sys.executable, "-m", module, "--production"])` resolves -both. +is what `_trigger` starts, and it now runs one leg: `screen_locker.screen_lock`. +Its alarm leg was removed in the same change that deleted `_alarm.py`. -## Production dependency installation — read this before adding any dependency +**When this package's module paths change, the orchestrator must change in the +same commit.** Leaving it pointing at a module that no longer exists caused a +month-long silent production failure once already (2026-05-28, orchestrator +still on `python_pkg.screen_locker.screen_lock` after screen-locker's +extraction): the alarm fired and dismissed correctly while the workout lock +crashed with `ModuleNotFoundError` on every run. -`wake-alarm.service` runs `/usr/bin/python` directly — **not** a venv. Any -new non-stdlib dependency (this package itself, `gatelock`, `python-kasa`, -anything added later) must be installed into system Python's *user* +## Production dependency installation + +`wake-alarm-trigger.service` runs `/usr/bin/python` directly — **not** a venv. +Any new non-stdlib dependency must go into system Python's *user* site-packages: ```bash /usr/bin/python3 -m pip install --user --break-system-packages -e . ``` -**Always run `install.sh` (or `pip install -e`) from a durable clone, not a -scratch directory.** `install.sh` does `pip install -e "$REPO_DIR"` — -editable, so a later `git pull` in that same clone updates the running -production code with no reinstall needed. The clone must live somewhere -permanent (this repo's convention: `~/wake-alarm`, mirroring `~/screen-locker` -for the screen-locker package) — if you `pip install -e` from `/tmp/...` or -run `pip install "wake_alarm @ git+https://..."` as a one-off, you get a -non-editable snapshot frozen at that commit, and the next `git push` here -silently does **not** reach the running service. +**Run `install.sh` (or `pip install -e`) from a durable clone**, not a scratch +directory — it is editable, so a later `git pull` in that clone updates the +running production code. From `/tmp` or via `pip install "wake_alarm @ git+..."` +you get a snapshot frozen at that commit, and the next push silently does not +reach the running service. Convention: `~/wake-alarm`, mirroring +`~/screen-locker`. -`install.sh` already does this. **If you add a dependency and only install it -into a dev venv, the production service will silently fail with -`ModuleNotFoundError` on its next run** — this exact gap caused a 3-day -diet_guard production outage (2026-06-19 to 2026-06-22) for the sibling -`gatelock` migration. Always verify against -`/usr/bin/python3 -c "import "`, not just the dev venv, before -considering a dependency change done. +If you add a dependency and only install it into a dev venv, the service fails +with `ModuleNotFoundError` on its next run — this exact gap caused a 3-day +diet_guard outage. Verify against `/usr/bin/python3 -c "import "`. + +## Uninstalling + +`uninstall.sh`, never `rm -rf` on the repo. `/usr/local/bin/shutdown` is +root-owned and shadows `/usr/bin/shutdown`; deleting the repo leaves it in +place, and with the alarm cache gone it falls through to +`sudo rtcwake … ; exec systemctl hibernate` — so `shutdown now` would silently +stop being a poweroff, every day, forever. + +**`/etc/sudoers.d/wake-alarm` is deliberately left in place** even by a full +uninstall: screen-locker's own `_wake_alarm.py:_schedule_rtcwake` still shells +out to `sudo rtcwake`, and removing the entry here breaks a live feature in +another repo with no error message anywhere. ## Operational gotchas -- **`python-kasa` is optional at runtime** (`_smart_plug.py` catches - `ImportError` and disables smart-plug control with a warning log), but it - *is* a hard dependency for this repo's own tooling (mypy/pylint/tests need - it importable) — see `pyproject.toml`/`requirements.txt`. -- **The `wave` module needs special pylint handling.** `_audio.py` opens WAV - files in write mode; pylint's stdlib stub infers the read-mode overload and - wrongly flags `setnchannels`/`setsampwidth`/`setframerate`/`writeframes` as - missing. See the `generated-members` list in `pyproject.toml`'s - `[tool.pylint.typecheck]` — don't remove it if pylint starts complaining - about `wave.Wave_write`. - **`wake_state.json` is runtime state, not tracked.** It holds the - HMAC-signed dismissal record for the current alarm day, was accidentally - committed in the monorepo, and must stay gitignored here. + HMAC-signed once-per-day latch. It was accidentally committed in the + monorepo and must stay gitignored here. +- **`WAKE_STATE_FILE` is redirected in `tests/conftest.py`, not per-file.** + `_trigger` writes it now, and the trigger tests never redirected it — a + per-file redirect is exactly the kind that gets forgotten when a new writer + appears, and the failure is silent: a green suite that quietly stamped the + live state file. ## Commands - Run tests: `python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100` +- Phone tests: `cd phone_app && flutter test && flutter analyze` - Lint: `pre-commit run --all-files` -- Test the lock manually (safe, closeable): `python -m wake_alarm._alarm --demo` -- Show the resolved alarm: `python -m wake_alarm._sync --show` (add - `--no-cache` to force a live GitHub read) +- Show the resolved alarm: `python -m wake_alarm._sync --show` (`--no-cache` forces a live read) - Show what the next tick would do: `python -m wake_alarm._trigger --dry-run` - Watch it live: `journalctl --user -fu wake-alarm-trigger.service` -- Install for production: `bash install.sh` +- Install: `bash install.sh` — Uninstall: `bash uninstall.sh` - Gate the shell/system scripts: `bash scripts/vmbox/harness.sh` - (disposable Arch VM; coverage and limits in **`DOCS-vmbox.md`** — notably - *not* audio, the phone, or the bootloader). + (disposable Arch VM; see **`DOCS-vmbox.md`**) +- Ring the phone for a manual test: `lib/main_ring_probe.dart` (see + `DOCS-phone-alarm.md` — it starts a **real** alarm, and the new sequence is + what stops it) The dev venv lacks `gatelock`, so anything importing `_state` (including `_trigger`) needs `/usr/bin/python3`, not `.venv/bin/python`. ## Do NOT -- Don't add a dependency without doing the production install-path check - above. `_sync.py`'s **GitHub** path is stdlib-only for exactly this reason — - don't "improve" it to `requests`, and don't make the `crdt_sync` import - unconditional: the `try/except ImportError` guard is what keeps a failed - install from killing the trigger. -- Don't forget the orchestrator when changing this package's module path — - see "The morning_routine orchestrator lives elsewhere" above. -- Don't give `wake-alarm.service` an `[Install]` section: it fires at login - time, not alarm time. -- Don't point the trigger at `wake-alarm.service`; it must start - `morning-routine.service`, or the workout lock never runs. -- Don't raise the log level of `_sync`'s degraded-read paths: the per-minute - tick turns any WARNING there into 1440 journal lines a day. +- Don't add a dependency without the production install-path check above. + `_sync.py`'s GitHub path is stdlib-only for that reason — don't "improve" it + to `requests`, and don't make the `crdt_sync` import unconditional. +- Don't forget the orchestrator when changing this package's module paths. +- Don't point the trigger at anything but `morning-routine.service`, or the + workout lock never runs. +- Don't remove the trigger's once-per-day latch. +- Don't raise the log level of `_sync`'s degraded-read paths. +- Don't let the bypass skip the walk, and don't add a challenge kind that + hides its own prompt. +- Don't delete `/etc/sudoers.d/wake-alarm`. - Don't commit `wake_state.json`. diff --git a/DOCS-architecture.md b/DOCS-architecture.md deleted file mode 100644 index f0646c4..0000000 --- a/DOCS-architecture.md +++ /dev/null @@ -1,45 +0,0 @@ -# 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/DOCS-curtains-hardware.md b/DOCS-curtains-hardware.md deleted file mode 100644 index 10e29ad..0000000 --- a/DOCS-curtains-hardware.md +++ /dev/null @@ -1,85 +0,0 @@ -# Curtains: what to buy, and what the code already does - -Item 5 of the hardening plan — "sun curtains open automatically on alarm -trigger". The **software is shipped and wired**; there is no hardware yet. The -alarm already calls `open_curtains()` on its startup path, next to -`turn_on_plug()`, and the default driver logs what it would have done. - -So buying a motor is a config file, not a code change: - -```bash -cat > ~/.config/wake_alarm/curtains.json <<'JSON' -{"driver": "noop"} -JSON -``` - -Absent file, malformed file, or an unknown driver name all fall back to the -no-op with a WARNING. A driver that throws is caught and the alarm rings on — -a jammed motor must never be a reason not to be woken. - -## Why curtains are worth having at all - -The audio failsafe silences the alarm after `FAILSAFE_SILENCE_SECONDS` -(300 s). Daylight is the one wake cue that keeps working after that, and the -only one that does not depend on a speaker, a sink, or a working HDMI -handshake — the exact chain that made this alarm silent in the first place. -It is also the cue that keeps working while you are in the other room solving -the phone's challenge. - -## Adding a driver - -`_curtains.py` has a two-method `CurtainDriver` protocol (`open`, `close`) and -a `_DRIVERS` registry. A new motor is one class plus one registry entry. - -Three rules, all of which the existing no-op and `_smart_plug.py` follow: - -1. **Import the dependency inside the factory, guarded.** The service runs - `/usr/bin/python`, not a venv, so a library that failed to install must - degrade to the no-op rather than kill the alarm process. -2. **Return, never raise.** `open_curtains()` catches, but a driver that - blocks forever cannot be caught — give every network call a timeout, the - way `TAPO_TIMEOUT_SECONDS` does. -3. **Never block the alarm.** The call is on the startup path, ahead of the - first beep. - -## Hardware options - -Nothing here is bought or tested — this is a shortlist, not a recommendation -from use. Ordered by how well each fits the constraints above. - -### 1. Retrofit chain/rod motor (best fit) - -A motor that clamps onto the existing rail, so no new curtains and no -landlord conversation. Look for: - -- **Local control** — an HTTP or MQTT endpoint on the LAN. A cloud-only - device makes a 07:00 wake dependent on someone else's uptime, which is - precisely the dependency `_sync.py` already works hard to bound. -- **A physical manual override**, so a dead motor is not a dark room. -- **Mains power, not battery.** A battery that dies quietly gives you a - failure with no symptom until the morning it matters. - -Zigbee/Z-Wave models need a hub; a Wi-Fi model with a local API needs none -and is the simpler driver to write. - -### 2. Tapo/Kasa-driven mains switch (cheapest path) - -If a curtain motor with a hard switch is already in place, a second smart plug -would work through `python-kasa`, which this repo already depends on. Cheapest -by far, but it only works for a motor that opens on power-up — most do not. - -### 3. Nothing (the current state) - -Genuinely fine. The no-op driver is not a stub awaiting replacement; it is the -correct behaviour for a machine with no motor, and it keeps the log event and -the call site real so nothing has to be rediscovered later. - -## What is deliberately not here - -- **No cloud integrations.** See the local-control point above. -- **No scheduling of its own.** The alarm decides when morning is; a curtain - motor with its own timer would be a second source of truth, and the whole - design of this repo is that the phone is the only one. -- **No "close at night" automation.** `CurtainDriver.close()` exists so a - driver has both ends implemented, but nothing calls it yet. Closing belongs - to the night lockdown (item 13), not to the alarm. diff --git a/DOCS-speaker-recommendation.md b/DOCS-speaker-recommendation.md deleted file mode 100644 index 2d4e388..0000000 --- a/DOCS-speaker-recommendation.md +++ /dev/null @@ -1,100 +0,0 @@ -# Speaker recommendation — bedroom wake alarm - -Item 7 of the alarm-hardening work. **Prose only; no code changes here.** - -## Why the software cannot solve this - -A powered-off speaker is **undetectable from software**. Measured on this -machine: the analog sink is present and `IDLE` whether or not the amplifier has -mains power. - -``` -$ pactl list short sinks -61 alsa_output.pci-0000_0d_00.6.analog-stereo PipeWire s32le 2ch 48000Hz IDLE -``` - -Nothing in `_list_real_sinks()` can distinguish "playing to speakers" from -"playing to an amp that is switched off". Designing detection here would be -shipping a placebo. The fix is hardware plus the smart plug that is *already* -integrated. - -## The deciding spec is the power switch, not the LED - -`_alarm.py` already calls `turn_on_plug()` when the alarm fires and -`turn_off_plug()` from `on_close()` — which runs on every exit path including -SIGTERM. So the "cut mains overnight, restore at alarm time" architecture is -already implemented; it simply has no plug configured yet -(`~/.config/wake_alarm/tapo.json` does not exist). - -That makes one criterion pass/fail, ahead of everything else: - -> **GATE — the speaker must come back ready to pass audio when mains returns,** -> with no button press and no standby state. - -A **latching mechanical** switch (rear rocker, or a volume pot with a click-off -detent) holds its position through a mains cut, so the unit re-energises in the -state it was left in. A **momentary / soft-touch / microcontroller** button has -no mechanical memory and boots into whatever firmware defaults to — usually -standby. That is disqualifying. - -**Rule of thumb: a remote control in the box = disqualified.** Anything with a -remote has a standby microcontroller. - -## Verified findings (official manuals / vendor FAQs only) - -| Model | Switch | LED | Verdict | -|---|---|---|---| -| **Edifier R1280T** ~299–309 zł | Rear latching rocker, **no remote** | Blue, front | ✅ **Top pick** | -| Edifier R1280DB / R1700BT / R1855DB | Has remote → soft standby | — | ❌ see below | -| **Mackie CR3-X** ~ | Rear rocker **+** front click-off pot, no standby | White logo ring, **cannot be defeated** | ⚠️ gate-passes, light fails | -| **M-Audio BX3** | Front click-detent pot (no rear switch) | Front LED | ✅ runner-up | -| **JBL 104** | Rear latching | Front ring around volume knob | ⚠️ auto-standby after 19 min | -| PreSonus Eris E3.5 (non-BT) | Front latching On/Off | Solid blue | ⚠️ ok, but see BT note | -| PreSonus Eris **BT** models | — | Red in standby | ❌ 15-min standby, **up to 30 s wake delay** | -| **SVEN SPS-702** ~239 zł | Rear mains rocker, no remote, analogue pots | Single pinhole | ✅ budget pick | -| Creative Pebble Pro | RGB button cycles to Off | persistence unverified | ⚠️ unverified | - -### The Edifier trap, worth stating explicitly - -Edifier's own support article (R1280DB/DBS standby): powering down with the -remote activates **standby** — the LED goes off but the physical switch stays -on, and *"the only way to wake the speakers is to use the same remote power -button."* A mains cycle will **not** clear that state. So any Edifier with a -remote breaks the smart-plug architecture. The plain **R1280T has no remote**, -which is exactly why it is the right pick. - -### Disqualified on wake latency - -PreSonus Eris BT: standby after 15 minutes, and waking costs *"delay up to half -a minute until audio playback is heard."* An alarm that starts half a minute -late is a broken alarm. JBL 104 has the same class of problem (19-minute -standby), though it can be disabled via a headphone-jack toggle sequence. - -## Recommendation - -1. **Edifier R1280T (~299–309 zł)** — passes the gate (rear latching rocker, no - remote, no standby state to get stuck in), loud enough at 42 W RMS, analogue - volume pot so the level survives a power cycle. Its blue front LED is the - one drawback → cover it with a LED-blackout sticker ("LEDim", biohac.pl, or - any Allegro *naklejki blokujące światło LED*). Cheap, reversible, no warranty - impact. -2. **SVEN SPS-702 (~239 zł)** — budget alternative, same topology (rear mains - rocker, no remote, analogue pots), 40 W RMS, single pinhole LED. - -**Avoid** anything with a remote, and avoid Mackie CR3-X for a *bedroom* (its -white logo ring cannot be turned off while powered — vendor FAQ confirms). - -## Analog, not USB - -Keep the speakers on **analog 3.5 mm**, not USB. A USB speaker disappears from -PipeWire when its mains is cut and re-enumerates on restore, reintroducing -exactly the `auto_null`-only sink race that caused the 2026-07-11 silent alarm. -An analog sink stays present regardless of the speaker's power state. - -## Required code change (tracked separately) - -`_alarm.py`'s `main()` runs `turn_on_plug()` immediately before -`WakeAlarm(...).run()` — **zero lead time**. An amplifier's soft-start / mute -relay typically delays output 1–5 s after mains is applied, which would swallow -the opening of the alarm. Add a configurable pre-roll delay between plug-on and -the first beep. diff --git a/DOCS-vmbox.md b/DOCS-vmbox.md index 9d3a2e8..9159246 100644 --- a/DOCS-vmbox.md +++ b/DOCS-vmbox.md @@ -28,42 +28,21 @@ those wrong on the host is not a failed test, it is a broken machine. | Case | What a pass means | |---|---| | `imports-from-system-python` | `wake_alarm` and `gatelock` import under `/usr/bin/python3`, the interpreter the units really use — not a venv | -| `trigger-timer-enabled` | `wake-alarm-trigger.timer` is enabled after `install.sh` | -| `alarm-service-never-auto-starts` | `wake-alarm.service` is still `static`. An `[Install]` section there fires the alarm at *login*, because `_should_run_alarm()` has no time gate | -| `shutdown-wrapper-shadows-real-shutdown` | `shutdown` resolves to `/usr/local/bin/shutdown`, ahead of `/usr/bin/shutdown` | -| `wrapper-parses-the-alarm-cache` | `jq` reads the `{"hour","minute","fetched_at"}` shape the wrapper expects — the only coupling left between it and `_sync.py`'s `_write_cache()` | +| `trigger-timer-enabled` | `wake-alarm-trigger.timer` is enabled after `install.sh`. Writing the unit file is not the same as systemd running it, and a timer that is present but disabled fails silently every morning | | `trigger-dry-run` | one tick reaches a decision without crashing | -| `inhibitor-never-claims-a-block-it-lacks` | see the caveat below — this is *not* "the power button was blocked" | -| `cut-short-alarm-refires` | an unfinished session record makes the trigger fire even with the catch-up window long closed | -| `alarm-window-opens` (`--window`) | the fullscreen Tk window really paints, on a real X server, verified by a host-side screenshot | `install.sh` itself running to completion is a precondition of all of them: if it fails, the harness stops with exit 2 and dumps the log. ## What it cannot cover — do not read a pass as covering these -- **Audio.** The guest has no `pactl` and no `paplay`. Every sink, card-profile - and reactivation path — the whole of item 6 — is untestable here and was - verified directly on the host instead. -- **That the power button is actually blocked.** The guest has no physical - power button, *and* its polkit refuses an inhibit from a non-interactive ssh - session outright (`Failed to inhibit: Access denied`). So the case asserts - something narrower but still useful: that the code never *claims* a block it - did not get. `REFUSED` is a passing outcome. -- **The phone.** Nothing about the Flutter app, the sync round-trip, or the - alarm the phone rings. -- **GPU and the real display outputs.** The guest's X server is not DP-2 and - HDMI-0, so multi-monitor surface placement proves nothing here. -- **Anything bootloader-related.** The guest is BIOS/GRUB; this machine is - UEFI. A guest pass on boot-adjacent work is actively misleading — a sibling - repo's `boot-repair` passes in the guest while deleting the only bootable - kernel on a UEFI host. -- **rtcwake actually waking the machine.** `rtcwake` exists in the guest, but a - VM's RTC alarm is not the host's firmware. - -**A sandbox pass never proves the host is fine.** It is an added first step: -sandbox → report → host run. Phrase results as "passed in vmbox, not verified -on the host". +- **The phone.** The entire dismiss sequence — two NFC tags, the step counter, + the challenges, the 5-minute bypass — lives in `phone_app/` and is verified + on the Pixel 6a, never here. This harness knows nothing about it. +- **The bootloader.** The guest is BIOS/GRUB, this PC is UEFI. Any + firmware/ESP work goes straight to the host. +- **A green run is not host verification.** Report it as "passed in vmbox, not + verified on the host", always. ## Guest provisioning, and why @@ -89,7 +68,7 @@ approximation, so a break in `install.sh` fails the gate. makes rsync write into `/home` and fail with permission errors that look like a guest problem. -## Cases this VM found that the host could not +## Cases this VM found that the host could not (historical) Both were live bugs, both passed the unit suite: diff --git a/README.md b/README.md index 729c030..530b9c3 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,78 @@ # wake_alarm -A phone-driven wake alarm for an always-on PC. The alarm time is whatever the -companion phone app last synced (via the shared `crdt_sync` transport over a -private GitHub repo); a per-minute systemd user timer fires at exactly that -time, showing a fullscreen, challenge-dismissed alarm with escalating audio -and optional TP-Link Tapo P110 smart-plug control. +**The phone is the alarm.** `phone_app/` arms and rings it, and getting past it +means walking between two NFC tags and answering three questions. This repo's +Python half no longer wakes anyone: all it does is read the alarm time the +phone published and start the morning routine (the workout lock) at that time. -Change the alarm on the phone at any hour and the PC follows within ~5 -minutes. If the PC does happen to be powered down, a `shutdown` wrapper -hibernates with `rtcwake` set to the same synced time, as a fallback. +## The dismiss sequence + +Strict, ordered, and latched — every stage required, and a stage once passed +never reverts: + +``` +tag 1 -> 25 steps + 2 min -> tag 2 -> 3 challenges +``` + +The two tags must be *distinct* and live in different rooms; the same id in +both slots is refused at registration. Answers are cumulative, so a wrong one +costs the attempt and nothing else. + +**One fallback, deliberately narrow.** After 5 minutes of ringing — or +immediately if NFC is off or fewer than two tags are registered — the two *tag* +stages are skipped and the challenge count rises to 6. The walk is never +skipped. Without a hatch of some kind, a dead tag at 06:00 leaves an alarm that +cannot be switched off at all. + +Details, and the things that are a hard crash or a silent no-ring: +**`DOCS-phone-alarm.md`**. + +## The PC half + +A per-minute systemd user timer (`wake-alarm-trigger.timer`) reads the alarm +the phone published through the shared `crdt_sync` transport, and at that time +starts `morning-routine.service`, which runs the workout lock. It records a +once-per-day latch so the rest of the catch-up window does not restart it. + +That is all it does. There is no alarm window, no audio, no smart plug, no +`rtcwake`, and no `shutdown` wrapper. ## Install ```bash -bash install.sh +bash install.sh # trigger timer +bash uninstall.sh # removes it, and any retired waking machinery ``` -This installs the package + dependencies into system Python's user -site-packages (the systemd units run `/usr/bin/python` directly, not a venv — -see `CLAUDE.md`), installs and enables the per-minute trigger timer, the -systemd-sleep resume hook, the `shutdown` wrapper that hibernates with -`rtcwake` as a fallback, and (optionally) `python-kasa` for smart-plug -control. +`install.sh` installs the package into system Python's user site-packages (the +units run `/usr/bin/python` directly, not a venv — see `CLAUDE.md`) and enables +the timer. It also clears the retired pieces an older install left behind, +notably `/usr/local/bin/shutdown`, which is root-owned and would otherwise keep +turning `shutdown now` into a hibernate forever. -Reading the phone's alarm needs a GitHub token with `repo` scope, at -`~/.config/wake_alarm/sync.json` as `{"token": "..."}`. If that file is -absent, a sibling app's `~/.config/{diet_guard,screen_locker}/sync_token` is -used instead, so one token covers every app syncing through `kuhyx/syncs`. +Reading the phone's alarm uses the shared `~/.config/crdt-sync/firebase.json`. +The GitHub mirror (fallback only) needs a `repo`-scoped token at +`~/.config/wake_alarm/sync.json` as `{"token": "..."}`. ## Usage ```bash -python -m wake_alarm._alarm --demo # test the alarm window (safe, closeable) -python -m wake_alarm._sync --show # what alarm time the PC resolves +python -m wake_alarm._sync --show # what alarm time the PC resolves python -m wake_alarm._trigger --dry-run # what the next tick would do journalctl --user -fu wake-alarm-trigger.service # watch it live ``` -The alarm fires automatically once installed; no manual invocation is needed -in normal operation. - ## Development ```bash python -m venv .venv && .venv/bin/pip install -r requirements.txt .venv/bin/pre-commit install && .venv/bin/pre-commit install --hook-type pre-push .venv/bin/python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100 +cd phone_app && flutter test && flutter analyze ``` -See `CLAUDE.md` for scheduling details, the hibernate/`rtcwake` mechanism, -the cross-repo `workout_log.json` read, and production deployment gotchas. +The dev venv lacks `gatelock`, so anything importing `_state` (including +`_trigger`) needs `/usr/bin/python3`, not `.venv/bin/python`. + +See `CLAUDE.md` for the sync resolution chain, the cross-repo coupling to +`morning_routine`, and production deployment gotchas. diff --git a/TODO-hardening-continue.md b/TODO-hardening-continue.md index 2ad8fce..5d01fe6 100644 --- a/TODO-hardening-continue.md +++ b/TODO-hardening-continue.md @@ -25,11 +25,12 @@ starting; this file deliberately does not duplicate it. ## Remaining, in order -1. **Item 3 leftover: the QR scanner UI only.** `mobile_scanner`, feeding - `RingScreen.onQrScanned`, which already exists and is unit-tested. The - payload is registered in Settings → "Room proof" and persisted. NFC and the - steps fallback both work, so the alarm is dismissable without this — QR is - the third mechanism, not a missing one. +1. ~~QR scanner UI~~ — **cancelled 2026-08-30.** QR was deleted rather than + finished: it had never been reachable at ring time (no scanner package, no + CAMERA permission, `onQrScanned` had no caller), and a printed code is + photographable, so completing it would have added a bypass to the very gate + the same change was hardening. The dismiss path is now a strict sequence: + tag 1 → 25 steps + 2 min → tag 2 → 3 challenges. 2. **Item 8, mutual gating.** Phone half is now in place. This is the phase that legitimately touches `_sync.py`, so it carries that split (see below). 3. **Item 13, black screen.** Different repo (`~/testsAndMisc/ @@ -118,8 +119,9 @@ Two harness traps found while testing this, worth keeping: ### Still unverified -- The **QR** proof has no scanner UI (`mobile_scanner`); `checkQr` and its - persistence are unit-tested only. +- The **two-tag sequence** has not been walked end to end on the phone: only + one physical tag is registered, so every on-device run so far has fallen + through to the 5-minute bypass. Needs a second tag. - The **locked-keyguard** dismiss was not run to completion: the confirmed run was on an unlocked phone. The PIN-prompt path is implemented but unproven. diff --git a/install.sh b/install.sh index eee6609..5fa0fde 100755 --- a/install.sh +++ b/install.sh @@ -1,29 +1,31 @@ #!/bin/bash -# Install the weekend wake alarm systemd user service and sudoers entry. +# Install the wake-alarm trigger timer. # # Usage: bash install.sh # +# The PC no longer wakes anyone -- the phone does. All this installs is the +# per-minute trigger that starts morning-routine.service (and therefore the +# workout lock) at the time the phone last synced. +# # What it does: # 1. Installs wake_alarm + dependencies for /usr/bin/python -# 2. Installs system dependencies (alsa-utils, ddcutil) -# 3. Installs the systemd user units and enables the per-minute trigger timer -# (which fires the alarm at the time the phone synced) -# 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. 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 +# 2. Installs the systemd user units and enables the per-minute trigger timer +# 3. Removes the retired waking machinery from earlier installs +# +# Deliberately NOT done any more, and why: +# - no shutdown wrapper, no rtcwake, no sleep hook: the PC does not wake you +# - the /etc/sudoers.d/wake-alarm entry is left ALONE. screen-locker's own +# _wake_alarm.py still shells out to `sudo rtcwake`, so removing it here +# would silently break a live feature in another repo. set -euo pipefail # Refuse to run under sudo. This script must run as the logged-in user: it # installs *user* systemd units and calls `sudo` itself for the few root-owned # pieces. Under sudo, XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS are stripped, -# so `systemctl --user` fails at step 3 and every later step -- including the -# shutdown wrapper at step 6 -- is silently skipped while the earlier pip output -# still looks like success. It also runs pip as root, which leaves root-owned -# files in ~/.cache/pip. Failing loudly here beats a half-finished install. +# so `systemctl --user` fails and later steps are silently skipped while the +# earlier pip output still looks like success. It also runs pip as root, which +# leaves root-owned files in ~/.cache/pip. Failing loudly beats half-finishing. if [[ -n "${SUDO_USER:-}" || $EUID -eq 0 ]]; then echo "Error: do not run this script with sudo." >&2 echo "Run it as your normal user: bash $0" >&2 @@ -35,139 +37,35 @@ fi SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" readonly SCRIPT_DIR readonly REPO_DIR="$SCRIPT_DIR" -SERVICE_FILE="$SCRIPT_DIR/wake-alarm.service" -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_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" -SHUTDOWN_WRAPPER_DST="/usr/local/bin/shutdown" -SUDOERS_FILE="/etc/sudoers.d/wake-alarm" -RTCWAKE_BIN="/usr/sbin/rtcwake" +readonly TRIGGER_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-trigger.service" +readonly TRIGGER_TIMER_SRC="$SCRIPT_DIR/wake-alarm-trigger.timer" +readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user" -echo "=== Weekend Wake Alarm Installer ===" +echo "=== Wake Alarm Trigger Installer ===" -# 1. Install this package + its dependencies into system Python ------------- -echo "[1/9] Installing wake_alarm + dependencies for /usr/bin/python..." +echo "[1/3] Installing wake_alarm for /usr/bin/python..." +# Editable, and from this clone: a later `git pull` here then updates the +# running production code with no reinstall. A non-editable snapshot taken +# from a scratch directory silently stops tracking this repo. /usr/bin/python3 -m pip install --user --break-system-packages -e "$REPO_DIR" echo " Installed. Verifying import..." -/usr/bin/python3 -c "import wake_alarm; import gatelock" \ - && echo " wake_alarm and gatelock import cleanly from the system interpreter." +/usr/bin/python3 -c "import wake_alarm; import gatelock" +echo " wake_alarm and gatelock import under /usr/bin/python." -# 2. Install system dependencies -echo "[2/9] Checking system dependencies..." -if ! command -v speaker-test &>/dev/null; then - echo " Installing alsa-utils (required for speaker-test)..." - sudo pacman -S --noconfirm alsa-utils -else - echo " alsa-utils already installed" -fi - -# 3. Install systemd user units (alarm + the trigger timer that fires it) -echo "[3/9] Installing systemd user units..." +echo "[2/3] Installing systemd user units..." mkdir -p "$SYSTEMD_USER_DIR" -cp "$SERVICE_FILE" "$SYSTEMD_USER_DIR/wake-alarm.service" -cp "$TRIGGER_SERVICE_SRC" "$SYSTEMD_USER_DIR/wake-alarm-trigger.service" -cp "$TRIGGER_TIMER_SRC" "$SYSTEMD_USER_DIR/wake-alarm-trigger.timer" +cp "$TRIGGER_SERVICE_SRC" "$TRIGGER_TIMER_SRC" "$SYSTEMD_USER_DIR/" systemctl --user daemon-reload -echo " Installed wake-alarm.service, wake-alarm-trigger.{service,timer}" - -# wake-alarm.service must NOT auto-start: the PC is always on, so -# graphical-session.target is reached on every login and the alarm has no time -# gate — it would fire at login time. The timer owns firing (via -# morning-routine.service). Both guards matter under `set -euo pipefail`: -# a unit with no [Install] can't be enabled, and disabling a never-enabled unit -# can also return non-zero. -systemctl --user disable wake-alarm.service 2>/dev/null || true -echo " wake-alarm.service left disabled (started only by the trigger)" - systemctl --user enable --now wake-alarm-trigger.timer -echo " Trigger timer enabled and started (ticks once a minute)" +echo " Trigger timer enabled and started (ticks once a minute)." -# 4. Install systemd-sleep hook (restarts alarm after hibernate resume) -echo "[4/9] Installing systemd-sleep hook..." -sudo cp "$SLEEP_HOOK_SRC" "$SLEEP_HOOK_DST" -sudo chmod 0755 "$SLEEP_HOOK_DST" -echo " Installed to $SLEEP_HOOK_DST" - -# 5. Add sudoers entry for rtcwake (requires root) -echo "[5/9] Setting up sudoers for rtcwake..." -SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $RTCWAKE_BIN" -if [[ -f "$SUDOERS_FILE" ]] && grep -qF "$SUDOERS_LINE" "$SUDOERS_FILE"; then - echo " Sudoers entry already exists" -else - echo " Adding sudoers entry (requires sudo)..." - echo "$SUDOERS_LINE" | sudo tee "$SUDOERS_FILE" > /dev/null - sudo chmod 0440 "$SUDOERS_FILE" - echo " Added: $SUDOERS_LINE" -fi - -# 6. Install shutdown wrapper (/usr/local/bin/shutdown shadows /usr/bin/shutdown) -echo "[6/9] Installing shutdown wrapper..." -sudo cp "$SHUTDOWN_WRAPPER_SRC" "$SHUTDOWN_WRAPPER_DST" -sudo chmod 0755 "$SHUTDOWN_WRAPPER_DST" -echo " Installed to $SHUTDOWN_WRAPPER_DST" -echo " 'shutdown now' will now hibernate (not poweroff) on alarm nights." - -# 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)..." -if python -c 'import kasa' 2>/dev/null; then - echo " python-kasa already installed" -elif command -v yay &>/dev/null; then - yay -S --noconfirm --needed python-kasa -else - echo " WARNING: yay not found; install python-kasa manually for smart-plug support" >&2 -fi -if [[ ! -f "$HOME/.config/wake_alarm/tapo.json" ]]; then - echo " NOTE: ~/.config/wake_alarm/tapo.json not found — smart-plug control is disabled." - echo " Create it (mode 0600) with keys: host, email, password." -fi - -# 9. Install ddcutil for DDC/CI monitor power control -# ddcutil lets the alarm force the G27Q on via DDC/CI even when the monitor -# was physically powered off (power button), bypassing DPMS limitations. -echo "[9/9] Installing ddcutil (DDC/CI monitor power control)..." -if command -v ddcutil &>/dev/null; then - echo " ddcutil already installed" -else - sudo pacman -S --noconfirm ddcutil - echo " ddcutil installed" -fi -# ddcutil needs access to /dev/i2c-* — add user to i2c group if it exists. -if getent group i2c &>/dev/null; then - if ! id -nG "$USER" | grep -qw i2c; then - sudo usermod -aG i2c "$USER" - echo " Added $USER to i2c group (re-login required for group to take effect)" - else - echo " $USER already in i2c group" - fi -else - echo " i2c group not found — ddcutil will run via sudo" -fi +echo "[3/3] Removing retired waking machinery from earlier installs..." +bash "$SCRIPT_DIR/uninstall.sh" --retired-only +echo echo "=== Installation complete ===" -echo "The trigger timer ticks once a minute and fires the alarm at the time" -echo "the phone last synced (needs ~/.config/wake_alarm/sync.json with a" -echo "repo-scoped GitHub token — see MIGRATION.md)." -echo "Check what it resolves right now: python -m wake_alarm._sync --show" -echo "If the PC is powered down instead, the shutdown wrapper hibernates with" -echo "rtcwake set to the same alarm, and the sleep hook runs it on resume." -echo "To test now: python -m wake_alarm._alarm --demo" +echo "The timer ticks once a minute and starts morning-routine.service at the" +echo "time the phone last synced." +echo "Check what it resolves right now: python -m wake_alarm._sync --show" +echo "Check what the next tick would do: python -m wake_alarm._trigger --dry-run" +echo "Watch it live: journalctl --user -fu wake-alarm-trigger.service" diff --git a/pyproject.toml b/pyproject.toml index 18d010d..8a0564e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "wake-alarm" version = "1.0.0" -description = "Phone-driven wake alarm with fan ramp and smart-plug control" +description = "Trigger that starts the morning routine at the phone's alarm time" requires-python = ">=3.14" dependencies = [ "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.2#subdirectory=gatelock", @@ -13,7 +13,6 @@ dependencies = [ # falls back to its stdlib GitHub path if the import fails, so a broken # install degrades the read instead of killing the per-minute trigger. "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync", - "python-kasa==0.10.2", ] # Package discovery must be explicit: phone_app/ is a Flutter app, not a Python @@ -130,10 +129,6 @@ disable = [ "use-implicit-booleaness-not-comparison", "use-implicit-booleaness-not-comparison-to-string", "use-implicit-booleaness-not-comparison-to-zero", - # consider-using-with (R1732): several subprocess.Popen calls are - # intentionally fire-and-forget — the process must outlive the calling - # scope and is polled/killed later, so a `with` block is wrong here. - "consider-using-with", ] [tool.pylint.design] @@ -143,9 +138,6 @@ max-attributes = 10 [tool.pylint.typecheck] # unittest.mock.MagicMock generates assertion/introspection methods at runtime. -# wave.open(path, "wb") returns a Wave_write, but pylint's stdlib stub infers the -# read-mode Wave_read overload and wrongly reports its setter/writer methods as -# missing — list them so the write API is recognised. generated-members = [ ".*\\.assert_called_once_with", ".*\\.assert_called_once", @@ -155,11 +147,6 @@ generated-members = [ ".*\\.call_args", ".*\\.call_args_list", ".*\\.call_count", - ".*\\.setnchannels", - ".*\\.setsampwidth", - ".*\\.setframerate", - ".*\\.writeframes", - ".*\\.writeframesraw", ] [tool.bandit] diff --git a/requirements.txt b/requirements.txt index 1658c8c..842ba7b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,7 @@ # Wake Alarm — runtime + development dependencies -# Runtime: tkinter/json/hmac/wave/subprocess (stdlib) plus gatelock, -# crdt-sync and python-kasa below. crdt-sync and python-kasa are both -# optional at runtime — their modules degrade gracefully if missing (the -# alarm read falls back to GitHub, smart-plug control disables itself) — but -# both are required here for mypy/pylint/tests. +# Runtime: json/hmac/subprocess (stdlib) plus gatelock and crdt-sync below. +# crdt-sync is optional at runtime — the alarm read falls back to GitHub if +# it is missing — but it is required here for mypy/pylint/tests. bandit==1.9.4 codespell==2.4.3 coverage==7.16.0 @@ -17,5 +15,4 @@ pytest-cov==7.1.0 pytest-randomly==4.1.0 pytest-sugar==1.1.1 pytest-xdist==3.8.0 -python-kasa==0.10.2 ruff==0.16.5 diff --git a/scripts/verify_screen_fits.py b/scripts/verify_screen_fits.py deleted file mode 100755 index bb1baec..0000000 --- a/scripts/verify_screen_fits.py +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/env python3 -"""Assert the alarm's screens fit a 1366x768 display. - -The alarm ``place``s a centred frame, and a ``place``d frame takes its -*requested* size: when it is taller than the screen the parent shears equal -amounts off the top **and** the bottom -- which here means losing "WAKE UP!" -and the answer entry at once, on a screen whose entire purpose is to be -answered before it will stop. There is no scrollbar and nothing to indicate -anything is missing, so "it fits" is the only thing keeping it usable. - -Runs the real builders against a real (throwaway) X display, because widget -heights come from the font engine and nothing short of rendering can answer -this honestly: - - cd ~/wake-alarm && python3 -m scripts.verify_screen_fits - -Run as a *module*, not a path: ``-m`` puts the repo root on -``sys.path`` so ``wake_alarm`` resolves from the checkout. Running it as a -path only works where the package happens to be pip-installed, which is -why it passed locally and died in CI. -""" - -from __future__ import annotations - -import logging -import os -from pathlib import Path -import shutil -import subprocess -import sys -import tkinter as tk -from typing import TYPE_CHECKING - -from gatelock import LockConfig, measure_fit, report_fit - -from wake_alarm._alarm_view import _build_alarm_view -from wake_alarm._challenges import ( - _make_flash_challenge, - _make_math_challenge, - _make_reverse_challenge, - _make_sequence_challenge, - _make_sort_challenge, -) -from wake_alarm._escape_flow import EscapeCallbacks, EscapeDialog, build_tracker - -if TYPE_CHECKING: - from collections.abc import Callable - - from gatelock import FitResult, ScrollableSurface - -_logger = logging.getLogger(__name__) - -INNER_ENV = "_SCREEN_FITS_CHECK_INNER" - -# The panel this app has to fit: the machine it runs on. -WIDTH = 1366 -HEIGHT = 768 - - -def reexec_under_xvfb() -> int: - """Re-run this script on a throwaway X display, returning its exit code.""" - xvfb_run = shutil.which("xvfb-run") - if xvfb_run is None: - _logger.error( - "xvfb-run not found. Install it with: sudo pacman -S --needed " - "xorg-server-xvfb" - ) - return 1 - env = dict(os.environ, **{INNER_ENV: "1"}) - return subprocess.run( - [ - xvfb_run, - "-a", - # Bigger than the size under test, so the surface is sized by the - # geometry the harness sets rather than clamped by the display. - "-s", - "-screen 0 1600x1200x24", - sys.executable, - "-m", - "scripts.verify_screen_fits", - ], - check=False, - cwd=Path(__file__).resolve().parent.parent, - env=env, - ).returncode - - -def _placed_child(parent: tk.Misc) -> tk.Misc: - """Return the ``place``d descendant that carries the screen's height. - - ``place`` does not propagate a child's size to its parent, so measuring - the parent would report an empty screen. Falls back to the parent itself - when nothing is placed, which measures correctly for packed layouts. - """ - for child in parent.winfo_children(): - if child.winfo_manager() == "place": - return child - found = _placed_child(child) - if found is not child: - return found - return parent - - -# Every challenge kind, not a random one: the flash kind renders its code at -# 2.25x the display token where the others use 1.5x, and the sequence kind's -# display string is the longest. A hook that picked one at random would pass -# or fail by dice roll -- which is worse than no hook. -_CHALLENGE_KINDS = ( - ("math", _make_math_challenge), - ("flash", _make_flash_challenge), - ("sort", _make_sort_challenge), - ("sequence", _make_sequence_challenge), - ("reverse", _make_reverse_challenge), -) - - -def _build_alarm(surface: ScrollableSurface, make: Callable[[], object]) -> tk.Misc: - """Paint one dismiss challenge and return the frame to measure.""" - _build_alarm_view( - surface.content, - make(), - LockConfig(), - on_submit=lambda _event: None, - entry_var=tk.StringVar(master=surface.content), - ) - return _placed_child(surface.content) - - -def _build_escape(surface: ScrollableSurface) -> tk.Misc: - """Paint the escape-hatch form and return the frame to measure.""" - dialog = EscapeDialog( - surface.content, - LockConfig(), - build_tracker(), - EscapeCallbacks(on_granted=lambda: None, on_cancel=lambda: None), - ) - dialog.frame.pack() - return dialog.frame - - -def measure_all() -> list[FitResult]: - """Measure every screen at the supported size.""" - results = [ - measure_fit( - f"dismiss-challenge:{kind}", - lambda surface, make=make: _build_alarm(surface, make), - width=WIDTH, - height=HEIGHT, - ) - for kind, make in _CHALLENGE_KINDS - ] - results.append( - measure_fit("escape-hatch", _build_escape, width=WIDTH, height=HEIGHT) - ) - return results - - -def main() -> int: - """Measure every screen and fail if any of them overflows.""" - logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout) - if os.environ.get(INNER_ENV) != "1": - return reexec_under_xvfb() - - _logger.info("Measuring every alarm screen against a %dx%d panel:", WIDTH, HEIGHT) - if report_fit(measure_all()): - _logger.info("\nOK: every screen fits.") - return 0 - _logger.error( - "\nAt least one screen is taller than the display. A place-centred " - "surface clips at BOTH edges when that happens, so the overflow is " - "not merely hidden -- it is unrecoverable." - ) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/vmbox/check_inhibitor.py b/scripts/vmbox/check_inhibitor.py deleted file mode 100644 index 0e99379..0000000 --- a/scripts/vmbox/check_inhibitor.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Guest-side check: does the power-key inhibitor register with logind? - -Run by ``guest_cases.sh`` inside a vmbox sandbox. Prints a single line the -shell case compares exactly, so the harness adjudicates on a string rather -than on a human reading a log. - -**What this proves and what it does not.** The guest has no physical power -button, so this shows the inhibitor is *taken* and *released* -- not that a -real press was suppressed. That distinction is stated in ``DOCS-vmbox.md`` -rather than papered over. - -The guest's polkit also refuses an inhibit from a non-interactive ssh session, -so ``REFUSED`` is an expected and passing outcome. It is what caught the real -bug: ``Popen`` succeeded while ``systemd-inhibit`` had already exited 1, and -the alarm logged the power key as blocked when it was not. -""" - -from __future__ import annotations - -import subprocess -import sys -import time - -from wake_alarm._inhibit import ( - release_power_key_inhibitor, - take_power_key_inhibitor, -) - -_SETTLE_SECONDS = 1.0 - -# Absolute, so the check cannot be pointed at a different binary by PATH -- -# and so it matches how _inhibit.py resolves the tool it actually spawns. -_SYSTEMD_INHIBIT = "/usr/bin/systemd-inhibit" - - -def _inhibitor_list() -> str: - """Return logind's current inhibitor table as text.""" - return subprocess.run( - [_SYSTEMD_INHIBIT, "--list"], - capture_output=True, - text=True, - check=False, - ).stdout - - -def main() -> None: - """Take the inhibitor, observe it, release it, observe it gone. - - Writes ``REFUSED`` when logind declined, which the harness treats as a - *pass*: the point of the case is that the code never claims a block it did - not get. - """ - process = take_power_key_inhibitor() - if process is None: - sys.stdout.write("REFUSED\n") - return - - time.sleep(_SETTLE_SECONDS) - while_held = _inhibitor_list() - release_power_key_inhibitor(process) - time.sleep(_SETTLE_SECONDS) - after_release = _inhibitor_list() - - held = "handle-power-key" in while_held and "wake-alarm" in while_held - freed = "wake-alarm" not in after_release - sys.stdout.write( - f"{'HELD' if held else 'NOT-HELD'} {'FREED' if freed else 'NOT-FREED'}\n" - ) - - -if __name__ == "__main__": - main() diff --git a/scripts/vmbox/check_refire.py b/scripts/vmbox/check_refire.py deleted file mode 100644 index e4f3137..0000000 --- a/scripts/vmbox/check_refire.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Guest-side check: does a cut-short alarm re-fire on the next tick? - -Run by ``guest_cases.sh`` inside a vmbox sandbox. Prints one line -- three -decisions joined by dashes -- that the shell case compares exactly. - -This is the case a VM tests better than the host can: on the real machine the -only honest way to produce an unfinished session is to actually cut the power -mid-alarm. Here the alarm time is deliberately placed hours in the past, so -the catch-up window is closed and **only** an unfinished session can make the -trigger fire. -""" - -from __future__ import annotations - -import sys - -from wake_alarm._alarm_session import finish_session, start_session -from wake_alarm._sync import Alarm -from wake_alarm._trigger import _decide, _now - -_HOURS_AGO = 6 - - -def main() -> None: - """Print before-during-after firing decisions around a session record.""" - now = _now() - stale = Alarm((now.hour - _HOURS_AGO) % 24, 0, "remote") - - before, _ = _decide(now, stale) - start_session("wake-vmbox-harness") - during, _ = _decide(now, stale) - finish_session() - after, _ = _decide(now, stale) - sys.stdout.write(f"{before}-{during}-{after}\n") - - -if __name__ == "__main__": - main() diff --git a/scripts/vmbox/guest_cases.sh b/scripts/vmbox/guest_cases.sh index 191c303..a360436 100755 --- a/scripts/vmbox/guest_cases.sh +++ b/scripts/vmbox/guest_cases.sh @@ -15,10 +15,6 @@ set -uo pipefail readonly PY=/usr/bin/python3 -# The helper .py files ship beside this script, so each gets real linting -# and type checking instead of hiding inside a heredoc. -CASE_DIR="$(dirname "$(readlink -f "$0")")" -readonly CASE_DIR FAILURES=0 pass() { @@ -42,9 +38,9 @@ case_imports() { fi } -# --- Case: units land in the right enablement state -------------------------- -# wake-alarm.service must stay `static`. An [Install] section there fires the -# alarm at *login* time, because _should_run_alarm() has no time gate. +# --- Case: the trigger timer is actually enabled ----------------------------- +# install.sh writing the unit file is not the same as systemd running it, and +# a timer that is present but disabled fails silently every morning. case_units() { local name state name="trigger-timer-enabled" @@ -54,42 +50,6 @@ case_units() { else fail "$name" "expected enabled, got '$state'" fi - - name="alarm-service-never-auto-starts" - state="$(systemctl --user is-enabled wake-alarm.service 2>&1)" - if [[ "$state" == "static" || "$state" == "disabled" ]]; then - pass "$name" - else - fail "$name" "wake-alarm.service is '$state'; it must never auto-start" - fi -} - -# --- Case: the shutdown wrapper shadows /usr/bin/shutdown -------------------- -case_shutdown_wrapper() { - local name="shutdown-wrapper-shadows-real-shutdown" - local resolved - resolved="$(command -v shutdown)" - if [[ "$resolved" == "/usr/local/bin/shutdown" ]]; then - pass "$name" - else - fail "$name" "shutdown resolves to '$resolved', not the wrapper" - fi -} - -# --- Case: the wrapper reads the alarm cache, not the network ---------------- -# It parses ~/.config/wake_alarm/alarm_cache.json with jq. The shape is the -# only coupling left between the wrapper and _sync.py's _write_cache(). -case_wrapper_reads_cache() { - local name="wrapper-parses-the-alarm-cache" - local cache="$HOME/.config/wake_alarm/alarm_cache.json" - mkdir -p "$(dirname "$cache")" - printf '{"hour": 6, "minute": 45, "fetched_at": %d}\n' "$(date +%s)" > "$cache" - if jq -e '.hour == 6 and .minute == 45' "$cache" >/dev/null 2>&1; then - pass "$name" - else - fail "$name" "jq could not read the cache shape the wrapper expects" - fi - rm -f "$cache" } # --- Case: the trigger decides without crashing ------------------------------ @@ -102,80 +62,15 @@ case_trigger_dry_run() { fi } -# --- Case: the inhibitor reports honestly ------------------------------------ -# The guest's polkit denies an inhibit from a non-interactive ssh session -# ("Failed to inhibit: Access denied", exit 1), so what is asserted here is -# NOT that the power key gets blocked -- it is that the code never *claims* -# a block it did not get. That distinction is the bug this case found: Popen -# succeeded, the alarm logged "power key inhibited", and nothing was. -# -# The two accepted outcomes are therefore "took it and released it" and -# "logind refused and we said so". A silent third state is the failure. -# See DOCS-vmbox.md for why the block itself cannot be proven in a guest. -case_inhibitor() { - local name="inhibitor-never-claims-a-block-it-lacks" - "$PY" "$CASE_DIR/check_inhibitor.py" > /tmp/inhibit_result 2>/dev/null - local result - result="$(cat /tmp/inhibit_result 2>/dev/null)" - case "$result" in - "HELD FREED"|"REFUSED") - pass "$name ($result)" - ;; - *) - fail "$name" "expected 'HELD FREED' or 'REFUSED', got '$result'" - ;; - esac -} - -# --- Case: an unfinished session re-fires -------------------------------- -# The one case a VM tests better than the host: a real poweroff mid-alarm. -# Here we assert the decision the next boot's trigger tick would make. -case_refire() { - local name="cut-short-alarm-refires" - "$PY" "$CASE_DIR/check_refire.py" > /tmp/refire_result 2>/dev/null - local result - result="$(cat /tmp/refire_result 2>/dev/null)" - if [[ "$result" == "False-True-False" ]]; then - pass "$name" - else - fail "$name" "expected False-True-False, got '$result'" - fi - rm -f "$HOME/.config/wake_alarm/alarm_session.json" -} - -# --- Case: the alarm window really opens on a real X server ------------------ -# Verified by the HOST via `vm screenshot`; this half only launches it. -case_alarm_window() { - local name="alarm-window-opens" - "$PY" -m wake_alarm._alarm --demo --trigger-now >/tmp/alarm.log 2>&1 & - local pid=$! - sleep 12 - if kill -0 "$pid" 2>/dev/null; then - pass "$name" - echo "$pid" > /tmp/alarm.pid - else - fail "$name" "the alarm exited early: $(tail -3 /tmp/alarm.log)" - fi -} - main() { case "${1:-all}" in - window) - # Run alone: it leaves a fullscreen window up for the host to - # screenshot, which would hide every later case's output. - case_alarm_window - ;; all) case_imports case_units - case_shutdown_wrapper - case_wrapper_reads_cache case_trigger_dry_run - case_inhibitor - case_refire ;; *) - echo "usage: $0 [all|window]" >&2 + echo "usage: $0 [all]" >&2 exit 2 ;; esac diff --git a/scripts/vmbox/harness.sh b/scripts/vmbox/harness.sh index 5ca98a8..9c054f5 100755 --- a/scripts/vmbox/harness.sh +++ b/scripts/vmbox/harness.sh @@ -11,14 +11,13 @@ # What it deliberately cannot cover -- audio, the phone, real outputs, the # bootloader -- is in DOCS-vmbox.md. Read that before trusting a pass. # -# Usage: bash scripts/vmbox/harness.sh [--name ] [--keep] [--window] +# Usage: bash scripts/vmbox/harness.sh [--name ] [--keep] # ============================================================================ set -euo pipefail VM_NAME="wake-alarm-harness" KEEP=0 -RUN_WINDOW=0 SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" readonly SCRIPT_DIR # Absolute and symlink-resolved: `vm share` and `vm repo` both key off the @@ -29,11 +28,6 @@ readonly REPO_DIR SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME -# Packages the guest image lacks that this package genuinely needs. tk is not -# optional: without it `import tkinter` fails with a missing libtk8.6.so and -# the alarm cannot open its window. Measured on the stock guest, not guessed. -readonly GUEST_PACKAGES="tk" - # Printed by guest_cases.sh only when every case passed. See run_cases(). readonly GUEST_OK_SENTINEL="ALL-CASES-PASSED" @@ -46,7 +40,6 @@ Run wake_alarm's system-level cases in a disposable Arch VM. Options: --name Sandbox name (default: $VM_NAME) --keep Leave the sandbox running afterwards, for poking at - --window Also open the real alarm window and screenshot it -h, --help Show this help Exit codes: @@ -90,11 +83,6 @@ provision_sandbox() { step "Sharing the repo with the guest" vm share "$REPO_DIR" >/dev/null - step "Installing guest packages ($GUEST_PACKAGES)" - vm run "$VM_NAME" \ - "sudo pacman -S --noconfirm --needed $GUEST_PACKAGES" >/dev/null || - die "could not install guest packages" - # --worktree, not a clone: the point of the gate is to test the edits in # front of you, including the ones not committed yet. step "Copying the working tree into the guest" @@ -125,21 +113,6 @@ run_cases() { grep -q "$GUEST_OK_SENTINEL" <<<"$output" } -# The alarm's window needs a real X server, so the guest is the only place it -# can be seen at all without hijacking the host's screen. The screenshot is -# the artefact: it outlives the guest and is what a human actually checks. -run_window_case() { - step "Opening the alarm window in the guest" - local output - output="$(vm run "$VM_NAME" \ - 'bash ~/wake-alarm/scripts/vmbox/guest_cases.sh window' 2>&1)" - echo "$output" - grep -q "$GUEST_OK_SENTINEL" <<<"$output" || return 1 - local shot="/tmp/wake-alarm-window.png" - vm screenshot "$VM_NAME" "$shot" - echo "Alarm window screenshot: $shot" -} - cleanup() { if [[ $KEEP -eq 1 ]]; then echo "Sandbox '$VM_NAME' left running (--keep). Poke at it with: vm ssh $VM_NAME" @@ -157,9 +130,6 @@ main() { local status=0 run_cases || status=1 - if [[ $RUN_WINDOW -eq 1 ]]; then - run_window_case || status=1 - fi echo "============================================================" if [[ $status -eq 0 ]]; then @@ -182,10 +152,6 @@ while [[ $# -gt 0 ]]; do KEEP=1 shift ;; - --window) - RUN_WINDOW=1 - shift - ;; -h|--help) usage ;; diff --git a/shutdown-wrapper.sh b/shutdown-wrapper.sh deleted file mode 100755 index bf61263..0000000 --- a/shutdown-wrapper.sh +++ /dev/null @@ -1,113 +0,0 @@ -#!/bin/bash -# Wrapper for /usr/bin/shutdown that redirects to rtcwake -m disk on alarm -# nights (every day of the week). This ensures that both the automated -# systemd timer AND manual "shutdown now" hibernate correctly so the PC -# wakes for the morning alarm. -# -# Install to /usr/local/bin/shutdown (takes priority over /usr/bin/shutdown -# because /usr/local/bin appears first in PATH). -# -# This is the FALLBACK path, for the rare night the PC is actually powered -# down. The primary path is wake-alarm-trigger.timer, which ticks once a -# minute on the always-on machine and fires at the phone's alarm time without -# any rtcwake involvement (see wake_alarm/_trigger.py). -# -# Wake time is read from ~/.config/wake_alarm/alarm_cache.json, the on-disk -# cache that wake-alarm-trigger.timer refreshes every minute from the alarm -# the phone app pushes. Falls back to WAKE_AFTER_HOURS if that cache is -# missing or unusable. -# -# Deliberately a *local file read*, with no network call and no credentials. -# This script runs as /usr/local/bin/shutdown, at the one moment the machine -# is going down; a remote fetch there can block on a dying network, and under -# Firebase it could not work at all, because an ID token lasts about an hour -# and refreshing one is beyond what a shell script should be doing with a -# secret. The Python side already holds the credentials and already keeps this -# cache fresh, so the backend it syncs against is now invisible here: this -# wrapper needs no changes when GitHub is retired. -# -# The trade-off is bounded and acceptable: a cache older than the trigger's -# refresh interval still reflects the last alarm the phone published, and the -# alarm changes far more rarely than once a minute. MAX_CACHE_AGE_SECONDS -# below only rejects a cache so old it suggests the trigger has been dead for -# days, in which case the fixed fallback is the safer answer. - -set -euo pipefail - -REAL_SHUTDOWN=/usr/bin/shutdown -RTCWAKE=/usr/sbin/rtcwake -# Fallback: how many hours after shutdown to wake when Gist is unavailable. -# Must match WAKE_AFTER_HOURS in wake_alarm/_constants.py. -WAKE_AFTER_HOURS=8 - -# Pass through reboots and cancel commands unchanged. -for arg in "$@"; do - case "$arg" in - -r|--reboot|-c|--cancel) - exec "$REAL_SHUTDOWN" "$@" - ;; - esac -done - -ALARM_CACHE="$HOME/.config/wake_alarm/alarm_cache.json" -# Reject a cache this old as evidence the trigger itself has stopped running. -# Generous on purpose (2 days): the alarm rarely changes, so a merely stale -# cache is still a far better answer than the fixed +8h fallback. Mirrors -# ALARM_CACHE_FILE in wake_alarm/_constants.py. -MAX_CACHE_AGE_SECONDS=$(( 2 * 24 * 3600 )) - -# Read the alarm the phone published, from the local cache the trigger keeps -# fresh. Returns the Unix epoch (seconds) of the next occurrence of that H:M. -# Exits non-zero if the cache is absent, malformed, or older than -# MAX_CACHE_AGE_SECONDS, so the caller can fall back. -# -# The cache is written by wake_alarm/_sync.py as -# {"hour": H, "minute": M, "fetched_at": } — keep this reader in -# step with _write_cache() there. Storing H/M rather than the raw crdt_sync log -# means this script no longer needs to understand the sync payload format at -# all, which is what makes it backend-agnostic. -get_synced_alarm_epoch() { - local hour minute fetched_at now_epoch age target_epoch - - [[ -f "$ALARM_CACHE" ]] || return 1 - - # One jq call for all three fields: -e fails on null, and the fixed field - # order lets a single read destructure them. - read -r hour minute fetched_at < <( - jq -er '"\(.hour) \(.minute) \(.fetched_at)"' "$ALARM_CACHE" 2>/dev/null - ) || return 1 - - [[ "$hour" =~ ^[0-9]+$ && "$minute" =~ ^[0-9]+$ ]] || return 1 - (( hour <= 23 && minute <= 59 )) || return 1 - - printf -v now_epoch '%(%s)T' -1 - # fetched_at is a float; strip the fraction rather than pulling in bc. - age=$(( now_epoch - ${fetched_at%%.*} )) - (( age >= 0 && age <= MAX_CACHE_AGE_SECONDS )) || return 1 - - target_epoch=$(date -d "today $hour:$minute" +%s) - if (( target_epoch <= now_epoch )); then - target_epoch=$(date -d "tomorrow $hour:$minute" +%s) - fi - echo "$target_epoch" -} - -# Every day of the week is an alarm day (date +%u: Mon=1 … Sun=7). -tomorrow_dow=$(date -d "tomorrow" +%u) -case "$tomorrow_dow" in - 1|2|3|4|5|6|7) - if wake_epoch=$(get_synced_alarm_epoch 2>/dev/null); then - logger -t shutdown-wrapper \ - "alarm cache: waking at $(date -d "@$wake_epoch") (epoch $wake_epoch)" - else - wake_epoch=$(( $(printf '%(%s)T' -1) + WAKE_AFTER_HOURS * 3600 )) - logger -t shutdown-wrapper \ - "alarm cache unusable — fallback +${WAKE_AFTER_HOURS}h (epoch $wake_epoch)" - fi - sudo "$RTCWAKE" -m no -t "$wake_epoch" - exec /usr/bin/systemctl hibernate - ;; - *) - exec "$REAL_SHUTDOWN" "$@" - ;; -esac diff --git a/sleep-hook.sh b/sleep-hook.sh deleted file mode 100755 index 1cd90d3..0000000 --- a/sleep-hook.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash -# 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 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) - 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. -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 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 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) diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..9883d8b --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Remove the PC's waking machinery. +# +# Usage: +# bash uninstall.sh # remove everything this repo installed +# bash uninstall.sh --retired-only # remove only the retired waking pieces +# +# WHY THIS SCRIPT EXISTS, AND WHY `rm -rf` ON THE REPO IS NOT ENOUGH: +# `/usr/local/bin/shutdown` is root-owned and shadows `/usr/bin/shutdown` via +# PATH order. Deleting the repo does NOT remove it, and with the alarm cache +# gone it falls through to `sudo rtcwake ... ; exec systemctl hibernate` -- +# so `shutdown now` would silently stop being a poweroff, every day, forever. +# +# `/etc/sudoers.d/wake-alarm` is deliberately LEFT IN PLACE even by a full +# uninstall: screen-locker's own _wake_alarm.py:_schedule_rtcwake still shells +# out to `sudo rtcwake`, and removing the entry here would break a live feature +# in another repo with no error message anywhere. + +set -euo pipefail + +readonly SLEEP_HOOK_DST="/usr/lib/systemd/system-sleep/wake-alarm.sh" +readonly SHUTDOWN_WRAPPER_DST="/usr/local/bin/shutdown" +readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user" + +RETIRED_ONLY=0 + +usage() { + echo "Usage: $(basename "$0") [--retired-only]" + exit 0 +} + +while [[ $# -gt 0 ]]; do + case $1 in + --retired-only) RETIRED_ONLY=1; shift ;; + -h|--help) usage ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [[ -n "${SUDO_USER:-}" || $EUID -eq 0 ]]; then + echo "Error: do not run this script with sudo." >&2 + echo "Run it as your normal user: bash $0" >&2 + exit 1 +fi + +remove_retired() { + # The shutdown wrapper. Checked by content, not just existence, so this + # never deletes an unrelated /usr/local/bin/shutdown someone else put there. + if [[ -f "$SHUTDOWN_WRAPPER_DST" ]] \ + && grep -q 'wake-alarm\|rtcwake' "$SHUTDOWN_WRAPPER_DST"; then + echo " Removing $SHUTDOWN_WRAPPER_DST (restores real /usr/bin/shutdown)" + sudo rm -f "$SHUTDOWN_WRAPPER_DST" + else + echo " $SHUTDOWN_WRAPPER_DST: not ours or already gone" + fi + + if [[ -f "$SLEEP_HOOK_DST" ]]; then + echo " Removing $SLEEP_HOOK_DST" + sudo rm -f "$SLEEP_HOOK_DST" + else + echo " $SLEEP_HOOK_DST: already gone" + fi + + # wake-alarm.service is gone from the repo; clear any stale installed copy + # and its enable symlink. + if [[ -f "$SYSTEMD_USER_DIR/wake-alarm.service" ]]; then + echo " Removing stale wake-alarm.service" + systemctl --user disable wake-alarm.service >/dev/null 2>&1 || true + rm -f "$SYSTEMD_USER_DIR/wake-alarm.service" + else + echo " wake-alarm.service: already gone" + fi +} + +remove_trigger() { + echo " Disabling the trigger timer" + systemctl --user disable --now wake-alarm-trigger.timer >/dev/null 2>&1 || true + rm -f "$SYSTEMD_USER_DIR/wake-alarm-trigger.service" \ + "$SYSTEMD_USER_DIR/wake-alarm-trigger.timer" +} + +main() { + echo "=== Removing the PC's waking machinery ===" + remove_retired + + if [[ $RETIRED_ONLY -eq 0 ]]; then + remove_trigger + echo " Note: the workout lock no longer starts at the phone's alarm time." + fi + + systemctl --user daemon-reload + + echo + echo "Left in place on purpose:" + echo " /etc/sudoers.d/wake-alarm — screen-locker's own rtcwake path needs it." + echo + echo "Verify: command -v shutdown # expect /usr/bin/shutdown" +} + +main "$@" diff --git a/wake-alarm.service b/wake-alarm.service deleted file mode 100644 index a6c46ab..0000000 --- a/wake-alarm.service +++ /dev/null @@ -1,31 +0,0 @@ -[Unit] -Description=Weekend Wake Alarm -After=graphical-session.target - -[Service] -Type=simple -# DISPLAY mirrors 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. -# No PYTHONPATH needed: wake_alarm is pip-installed (see install.sh / -# README), so /usr/bin/python finds it via user site-packages. -Environment=DISPLAY=:0 -ExecStartPre=/bin/sleep 1 -ExecStart=/usr/bin/python -m wake_alarm._alarm --production -Restart=on-failure -RestartSec=10 - -# There is intentionally NO [Install] section, and nothing auto-starts this -# unit. The PC is always on now, so graphical-session.target is reached on -# every login — and _should_run_alarm() has no time gate, so a -# WantedBy=graphical-session.target here fires the alarm at *login time* on -# any alarm day (i.e. every day). That was the bug this design removed. -# -# What actually runs the alarm: wake-alarm-trigger.timer ticks once a minute -# and starts morning-routine.service at the phone's alarm time; that -# orchestrator runs `python -m wake_alarm._alarm` as a subprocess (the alarm -# first, the workout lock after). This unit survives only as a manual test -# handle: `systemctl --user start wake-alarm.service`. -# -# Do not "fix" the missing auto-start. diff --git a/wake_alarm/_alarm.py b/wake_alarm/_alarm.py deleted file mode 100644 index 3f79881..0000000 --- a/wake_alarm/_alarm.py +++ /dev/null @@ -1,249 +0,0 @@ -"""Wake alarm: escalating beeps behind a fullscreen dismiss challenge. - -Started by ``morning-routine.service``, never directly (AGENTS.md). Gate: -``_gating``. Sound: ``_beep_loop``. Timers: ``_alarm_timers`` and ``_alarm_flash``. -""" - -from __future__ import annotations - -import contextlib -from datetime import UTC, datetime -import logging -import sys -import threading -import tkinter as tk - -from gatelock import ( - GateRoot, - LockWindow, - SurfaceInfo, -) - -from wake_alarm._alarm_display import _restore_display -from wake_alarm._alarm_flash import AlarmFlashMixin -from wake_alarm._alarm_session import finish_session, start_session -from wake_alarm._alarm_state import _AlarmHardware, _AlarmProgress -from wake_alarm._alarm_timers import AlarmTimersMixin -from wake_alarm._alarm_view import _build_alarm_view, _ViewGroup -from wake_alarm._beep_loop import start_beep_thread -from wake_alarm._challenges import ( - _make_challenge, -) -from wake_alarm._constants import DISMISS_ROUNDS_REQUIRED -from wake_alarm._correlation import correlation_id, correlation_id_for_today -from wake_alarm._escape_flow import EscapeHatch, build_tracker -from wake_alarm._gating import parse_args, should_run_alarm -from wake_alarm._inhibit import ( - release_power_key_inhibitor, - take_power_key_inhibitor, -) -from wake_alarm._lock_setup import build_config, claim_arbiter -from wake_alarm._logging_setup import configure_logging -from wake_alarm._reactivate import start_alarm_reactivation -from wake_alarm._rounds import ( - BANNER_LINGER_MS, - normalise, - show_challenge, - show_dismissal, - show_round_progress, - show_wrong_answer, -) -from wake_alarm._sinks import ( - _activate_all_outputs, - _restore_audio_state, -) -from wake_alarm._smart_plug import turn_off_plug -from wake_alarm._startup import prepare_hardware -from wake_alarm._state import ( - save_wake_state, -) - -_logger = logging.getLogger(__name__) - - -class WakeAlarm(AlarmTimersMixin, AlarmFlashMixin): - """Fullscreen wake alarm with escalating beep and dismiss challenge.""" - - def __init__( - self, *, demo_mode: bool = False, correlation: str | None = None - ) -> None: - """Initialize the wake alarm. - - Args: - demo_mode: If True, use a smaller window and shorter timers. - correlation: The morning's correlation id, derived from the - *resolved alarm instant* by the caller. Defaults to one - derived from now, which is only right for a demo run. - """ - self.demo_mode = demo_mode - self.dismissed = False - self._stop_beep = threading.Event() - self._active = True - - self.root = GateRoot() - self.root.on_callback_error = self.on_callback_error - self.root.title("Wake Alarm" + (" [DEMO]" if demo_mode else "")) - config = build_config(demo_mode=demo_mode) - # A local, not an attribute: the arbiter is published here and then - # owned by LockWindow, which is what releases it on close. - arbiter = claim_arbiter(config) - - # Built before setup(): setup() calls build_surface for every live - # output, and those need the challenge and the shared entry variable. - self._progress = _AlarmProgress(current_challenge=_make_challenge()) - self._view = _ViewGroup(config, tk.StringVar(master=self.root)) - self._escape = EscapeHatch(build_tracker(), self._view, self._on_escape_granted) - - self._lock = LockWindow(self.root, config, hooks=self, arbiter=arbiter) - self._lock.setup() - self._update_timer() - if self._progress.current_challenge.kind == "flash": - self._start_flash_countdown() - self._schedule_code_refresh() - self._schedule_skip_window_close() - start_beep_thread(self._stop_beep, self._progress.started_at) - # Taken while the alarm is audible, released in on_close(). A short - # power-button press would otherwise end the alarm in one tap. - inhibitor = take_power_key_inhibitor() - # Written before anything can go wrong: a power cut from here on - # leaves a "ringing" record for _trigger to re-fire from on boot. - start_session(correlation or correlation_id(datetime.now().astimezone())) - self._hardware = _AlarmHardware( - audio_restore=_activate_all_outputs(), inhibitor=inhibitor - ) - # The first activation above runs before the monitor has finished its - # HDMI handshake, so the sink that matters most may not exist yet. - # Keep retrying in the background until the alarm is dismissed -- only - # a fresh set-card-profile can bring that sink into existence, so no - # amount of re-enumerating sinks in the beep loop would find it. - start_alarm_reactivation(self._stop_beep) - self._flash_step() - self._lock.grab_input() - - def build_surface(self, parent: tk.Misc, surface: SurfaceInfo) -> None: - """Paint the full challenge on one monitor. - - Called once per live output, and again whenever an output comes - back. Every copy shares one entry variable and one submit handler, - so answering on any monitor dismisses the alarm on all of them. - """ - view = _build_alarm_view( - parent, - self._progress.current_challenge, - self._view.config, - on_submit=self._on_submit, - entry_var=self._view.entry_var, - ) - view.output_name = surface.output_name - view.escape_button.configure(command=self._escape.open) - self._view.add(view) - - def teardown_surface(self, surface: SurfaceInfo) -> None: - """Forget the view for an output that went dark.""" - self._view.discard(surface.output_name) - - def on_focus_ready(self, surface: SurfaceInfo | None) -> None: - """Put keyboard focus on the dismiss-code entry once mapped. - - ``surface`` is None when no output is live -- the alarm is armed - and audible with nothing to show, so there is nothing to focus. - """ - if surface is None: - return - with contextlib.suppress(tk.TclError): - self._view.entry.focus_force() - - def on_callback_error(self) -> None: - """Surface an unexpected callback error without dropping the alarm.""" - with contextlib.suppress(tk.TclError): - self._view.status_label.configure( - text="Something went wrong — try again.", - ) - self._view.entry.focus_force() - - def on_close(self) -> None: - """Restore audio/display/plug; runs on every exit path, even SIGTERM.""" - self._stop_beep.set() - # Closing the session here, not in _dismiss_alarm, so the escape hatch - # and a SIGTERM close it too. The one path that cannot reach this is - # the power cut it exists to catch. - finish_session() - release_power_key_inhibitor(self._hardware.inhibitor) - _restore_audio_state(self._hardware.audio_restore) - _restore_display() - turn_off_plug() - - def _on_submit(self, _event: object = None) -> None: - """Handle challenge submission. - - Normalises input and compares to the current challenge answer. - Requires DISMISS_ROUNDS_REQUIRED correct entries in sequence — each - correct round generates a new random challenge so the user must stay - awake and re-engage each time. - """ - entered = normalise(self._view.entry.get()) - if entered != self._progress.current_challenge.answer: - show_wrong_answer(self._view, self._progress.current_challenge) - if self._progress.current_challenge.kind == "flash": - self._start_flash_countdown() - return - self._progress.rounds_completed += 1 - if self._progress.rounds_completed >= DISMISS_ROUNDS_REQUIRED: - self._dismiss_alarm(earned_skip=self._progress.skip_earnable) - return - self._progress.current_challenge = _make_challenge() - show_challenge(self._view, self._progress.current_challenge) - show_round_progress( - self._view, - completed=self._progress.rounds_completed, - required=DISMISS_ROUNDS_REQUIRED, - ) - if self._progress.current_challenge.kind == "flash": - self._start_flash_countdown() - - def _dismiss_alarm(self, *, earned_skip: bool) -> None: - """Dismiss the alarm and save state.""" - self._active = False - self.dismissed = True - self._stop_beep.set() - now_iso = datetime.now(tz=UTC).isoformat() - save_wake_state(dismissed_at=now_iso, skip_workout=earned_skip) - show_dismissal( - self._view.containers, - self._view.config, - earned_skip=earned_skip, - ) - self.root.after(BANNER_LINGER_MS, self._lock.close) - - def _on_escape_granted(self) -> None: - """The hatch was earned: silence the alarm, grant no workout skip.""" - _logger.warning("alarm dismissed via the escape hatch, no skip earned") - self._dismiss_alarm(earned_skip=False) - - def run(self) -> None: - """Start the alarm main loop, restoring hardware on every exit path.""" - self._lock.run() - - -def main() -> None: - """Entry point for the wake alarm daemon.""" - configure_logging() - - args = parse_args(sys.argv[1:]) - - if not args.trigger_now and not should_run_alarm(): - return - - _logger.warning( - "ALARM TRIGGERED at %s (demo=%s, trigger_now=%s)", - datetime.now(tz=UTC).isoformat(timespec="seconds"), - args.demo, - args.trigger_now, - ) - prepare_hardware() - alarm = WakeAlarm(demo_mode=args.demo, correlation=correlation_id_for_today()) - alarm.run() - - -if __name__ == "__main__": - main() diff --git a/wake_alarm/_alarm_display.py b/wake_alarm/_alarm_display.py deleted file mode 100644 index 1273540..0000000 --- a/wake_alarm/_alarm_display.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Display power and screensaver helpers for the wake alarm. - -Wakes monitors that may be physically powered off (via DDC/CI) or in DPMS -standby, and restores the screensaver once the alarm dismiss flow ends. -""" - -from __future__ import annotations - -import logging -import shutil -import subprocess - -_logger = logging.getLogger(__name__) - - -def _ddcutil_power_on() -> None: - """Power on all connected monitors via DDC/CI VCP code D6. - - This wakes monitors that were physically turned off with the power button - and therefore ignore DPMS signals. Falls back silently when ddcutil is - absent or returns an error (e.g. no i2c access yet). - """ - ddcutil = shutil.which("ddcutil") - if ddcutil is None: - _logger.warning("ddcutil not on PATH; skipping DDC/CI monitor power-on") - return - try: - result = subprocess.run( - [ddcutil, "setvcp", "D6", "01"], - check=False, - capture_output=True, - timeout=10, - ) - except OSError, subprocess.TimeoutExpired: - _logger.warning("ddcutil setvcp failed", exc_info=True) - return - if result.returncode != 0: - _logger.warning( - "ddcutil setvcp D6 01 exited %d: %s", - result.returncode, - result.stderr.decode(errors="replace").strip()[:200], - ) - else: - _logger.info("DDC/CI monitor power-on sent") - - -def _wake_display() -> None: - """Force the display on and disable screensaver during alarm. - - Sends both a DDC/CI hard power-on (for monitors powered off via the - power button) and a DPMS force-on (for monitors in standby). - """ - _ddcutil_power_on() - xset = shutil.which("xset") - if xset is None: - _logger.warning("xset not on PATH; skipping DPMS display wake") - return - for cmd in ( - [xset, "dpms", "force", "on"], - [xset, "s", "off"], - ): - subprocess.run(cmd, check=False, capture_output=True, timeout=5) - - -def _restore_display() -> None: - """Re-enable screensaver after the alarm ends.""" - xset = shutil.which("xset") - if xset is None: - _logger.warning("xset not on PATH; skipping display restore") - return - subprocess.run( - [xset, "s", "on"], - check=False, - capture_output=True, - timeout=5, - ) diff --git a/wake_alarm/_alarm_flash.py b/wake_alarm/_alarm_flash.py deleted file mode 100644 index 8996c64..0000000 --- a/wake_alarm/_alarm_flash.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Screen-flash timers: the flash challenge countdown and the colour pulse. - -Split out of ``_alarm.py`` for the 250-line cap, as a mixin rather than free -functions because each of these reschedules itself through ``root.after`` and -needs the alarm's own view and progress state -- the same shape as -``AlarmTimersMixin``. - -Two independent flashes live here and must not be confused: - -* the **challenge** countdown (:meth:`_start_flash_countdown`) shows a code for - ``DISMISS_FLASH_SECONDS`` and then hides it, so the user has to memorise it; -* the **background** pulse (:meth:`_flash_step`) alternates the window colour - forever, so the screen keeps demanding attention. - -Both stop the moment ``_active`` goes false, which is what keeps a dismissed -alarm from repainting a window that is on its way out. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from wake_alarm._constants import DISMISS_FLASH_SECONDS -from wake_alarm._rounds import ( - FLASH_PERIOD_MS, - apply_flash_colour, - hide_flash_code, - show_flash_countdown, -) - -if TYPE_CHECKING: # pragma: no cover - import tkinter as tk - - from wake_alarm._alarm_state import _AlarmProgress - from wake_alarm._alarm_view import _ViewGroup - - -class AlarmFlashMixin: - """Self-rescheduling flash timers for the code countdown and background.""" - - # Provided by WakeAlarm; declared so type checking sees them. - root: tk.Misc - _active: bool - _progress: _AlarmProgress - _view: _ViewGroup - - def _start_flash_countdown(self) -> None: - """Begin the flash countdown: show the code, then hide it.""" - self._progress.flash_remaining = DISMISS_FLASH_SECONDS - self._flash_tick() - - def _flash_tick(self) -> None: - """Count the code down, then replace it with placeholders.""" - if not self._active: - return - if self._progress.flash_remaining > 0: - show_flash_countdown(self._view, self._progress.flash_remaining) - self._progress.flash_remaining -= 1 - self.root.after(1000, self._flash_tick) - else: - hide_flash_code(self._view, self._progress.current_challenge) - - def _flash_step(self) -> None: - """Alternate the background colour to keep the screen attention-grabbing.""" - if not self._active: - return - cfg = self._view.config - colour = cfg.danger if self._progress.flash_on else cfg.bg - apply_flash_colour(self.root, self._view.containers, colour) - self._progress.flash_on = not self._progress.flash_on - self.root.after(FLASH_PERIOD_MS, self._flash_step) diff --git a/wake_alarm/_alarm_session.py b/wake_alarm/_alarm_session.py deleted file mode 100644 index 87ad51d..0000000 --- a/wake_alarm/_alarm_session.py +++ /dev/null @@ -1,244 +0,0 @@ -"""The fire-time record that makes a hard power-off survivable. - -The real gap behind item 2 is not the power button itself -- it is that -nothing on disk ever says *"an alarm is ringing right now and was never -dismissed"*. ``wake_state.json`` is written only on **dismissal**, so cutting -power mid-alarm leaves no trace at all: the next boot sees an undismissed day -that looks exactly like a day the alarm had not reached yet, and the catch-up -window has long since closed. Holding the power button therefore *works*, and -that is what this module removes. - -So the alarm writes a record the moment it starts ringing, and clears it when -the ring genuinely ends. ``_trigger`` re-fires on a record still marked -``ringing``, ignoring the catch-up window. - -**Deliberately no date field.** ``_state.py`` keys its record by a UTC date, -and ``screen_locker/_wake_state.py`` mirrors that helper to read the very same -file from a different repo -- so the UTC basis there is load-bearing and is not -touched. Rather than add a second date key on a different basis, this record -carries only ``fired_at`` as an aware ISO timestamp, and freshness is an -elapsed-time comparison. Timezone-agnostic by construction, so the local -midnight question never arises. - -**Bounded, because an unfinished session is not self-clearing.** If the alarm -process dies before dismissal -- Tk cannot open a display, a crash at startup --- nothing ever runs ``on_close()`` to mark the session finished, and an -unbounded re-fire rule would restart the alarm every single minute forever. -The record is therefore honoured only within ``REFIRE_MAX_AGE_SECONDS`` of -``fired_at``, and the ordinary day / dismissed / workout gates still apply on -top of it. - -Signed with :mod:`gatelock.log_integrity`, exactly like ``_state.py``, so the -record cannot simply be edited to say ``dismissed``. -""" - -from __future__ import annotations - -from datetime import UTC, datetime -import json -import logging - -from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac - -from wake_alarm._constants import ( - ALARM_SESSION_FILE, - CLOCK_SKEW_TOLERANCE_SECONDS, - REFIRE_MAX_AGE_SECONDS, -) - -_logger = logging.getLogger(__name__) - -STATE_RINGING = "ringing" -STATE_FINISHED = "finished" - - -def _now() -> datetime: - """Return the current instant, aware and local. The seam tests patch.""" - return datetime.now(tz=UTC).astimezone() - - -def _write(entry: dict[str, object]) -> bool: - """Sign and write *entry*, reporting whether it landed on disk.""" - signature = compute_entry_hmac(entry) - if signature is not None: - entry["hmac"] = signature - else: - _logger.warning("HMAC key unavailable — saving unsigned alarm session") - try: - ALARM_SESSION_FILE.parent.mkdir(parents=True, exist_ok=True) - with ALARM_SESSION_FILE.open("w") as handle: - json.dump(entry, handle, indent=2) - except OSError as exc: - _logger.warning("Failed to write the alarm session record: %s", exc) - return False - return True - - -def start_session(correlation: str) -> bool: - """Record that an alarm has started ringing. - - Args: - correlation: The derived correlation id for this morning, so the - record joins up with the JSONL log without a handshake. - - Returns: - True when the record was written. - """ - written = _write( - { - "state": STATE_RINGING, - "fired_at": _now().isoformat(), - "correlation": correlation, - } - ) - if written: - _logger.warning( - "Alarm session opened (%s) — a power-off now re-fires on boot", - correlation, - extra={"event": "session_opened", "correlation": correlation}, - ) - return written - - -def finish_session() -> bool: - """Mark the current session finished, however the alarm ended. - - Called from ``on_close()``, so it covers dismissal, the escape hatch and - SIGTERM alike -- every path where the ring genuinely stopped with the - machine still up. The one path it cannot cover is the one it exists for: - power cut mid-alarm, which leaves the record saying ``ringing``. - - Returns: - True when the record was written. - """ - entry = load_session() - if entry is None: - return False - return _write( - { - "state": STATE_FINISHED, - "fired_at": entry.get("fired_at"), - "correlation": entry.get("correlation"), - } - ) - - -def load_session() -> dict[str, object] | None: - """Load and verify the session record. - - Returns: - The record, or None when it is absent, unreadable, or fails its HMAC - check. A tampered record is treated as no record: it must not be - possible to *create* a ringing session by hand either. - """ - if not ALARM_SESSION_FILE.exists(): - return None - try: - with ALARM_SESSION_FILE.open() as handle: - entry = json.load(handle) - except OSError, json.JSONDecodeError: - _logger.warning("Cannot read the alarm session record") - return None - if not isinstance(entry, dict): - return None - if verify_entry_hmac(entry): - return entry - # An entry with no "hmac" field at all was written unsigned, because no - # HMAC key was available at fire time. gatelock's verify returns False for - # that case *and* for a real forgery, and treating them alike would be a - # silent fail-open: on a machine with no key provisioned, every session - # record would be unreadable and the power-off defence would simply not - # exist. Found by running the vmbox harness, where /etc/gatelock is absent. - # - # So: unsigned is accepted with a loud warning (it is no weaker than the - # no-record state it replaces), while a *present but wrong* signature is - # still rejected outright -- that one really is tampering. - if "hmac" not in entry: - _logger.warning( - "Alarm session is unsigned (no HMAC key on this machine) — " - "honouring it, but it offers no tamper protection", - extra={"event": "session_unsigned"}, - ) - return entry - _logger.warning("Alarm session HMAC verification failed") - return None - - -def _fired_at(entry: dict[str, object]) -> datetime | None: - """Parse the record's ``fired_at``, or None when it is unusable.""" - raw = entry.get("fired_at") - if not isinstance(raw, str): - return None - try: - parsed = datetime.fromisoformat(raw) - except ValueError: - return None - if parsed.tzinfo is None: - return None - return parsed - - -def unfinished_session_age(*, now: datetime | None = None) -> float | None: - """Return how long an unfinished alarm has been ringing, in seconds. - - Args: - now: Instant to measure against; defaults to the current local time. - - Returns: - The elapsed seconds when a ringing session is on disk and its - timestamp is usable, else None. A slightly future-stamped record is - clamped to zero rather than discarded -- see below. - """ - entry = load_session() - if entry is None or entry.get("state") != STATE_RINGING: - return None - fired_at = _fired_at(entry) - if fired_at is None: - _logger.warning("Alarm session has no usable fired_at; ignoring it") - return None - age = ((now if now is not None else _now()) - fired_at).total_seconds() - if age >= 0: - return age - # A record stamped in the future means the clock moved *backwards* since - # it was written -- NTP correcting after a resume, or RTC-vs-system skew - # at boot. This machine wakes from hibernate, so that is a routine event, - # not a broken one. Treating it as "not ringing" would fail open on - # exactly the scenario this record exists to close: alarm fires on a fast - # clock, the clock is corrected, and the power-off escape works again. - # So clamp to zero and let it re-fire. - if age < -CLOCK_SKEW_TOLERANCE_SECONDS: - _logger.warning( - "Alarm session is stamped %.0fs in the future — beyond the " - "clock-skew tolerance, ignoring it", - -age, - extra={"event": "session_clock_skew", "skew_seconds": -age}, - ) - return None - _logger.info("Alarm session is %.0fs in the future; treating it as fresh", -age) - return 0.0 - - -def should_refire(*, now: datetime | None = None) -> bool: - """Report whether an alarm was cut short and must ring again. - - Bounded on purpose: an alarm process that died before dismissal leaves a - record nothing will ever clear, so without the age limit this would - restart the alarm every minute indefinitely. - - Args: - now: Instant to measure against; defaults to the current local time. - - Returns: - True when a ringing session exists and is younger than the limit. - """ - age = unfinished_session_age(now=now) - if age is None: - return False - if age > REFIRE_MAX_AGE_SECONDS: - _logger.info( - "Ignoring an unfinished alarm session %.0fs old (limit %.0fs)", - age, - REFIRE_MAX_AGE_SECONDS, - ) - return False - return True diff --git a/wake_alarm/_alarm_state.py b/wake_alarm/_alarm_state.py deleted file mode 100644 index a696d2b..0000000 --- a/wake_alarm/_alarm_state.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Mutable state the alarm carries while it is ringing. - -Split out of ``_alarm.py`` for the 250-line cap. Kept as plain dataclasses so -the alarm's own module stays about behaviour rather than bookkeeping. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -import time -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import subprocess - - from wake_alarm._challenges import _Challenge - from wake_alarm._sinks import _AudioState - - -@dataclass -class _AlarmProgress: - """Mutable dismiss-challenge progress state.""" - - current_challenge: _Challenge - started_at: float = field(default_factory=time.monotonic) - skip_earnable: bool = True - rounds_completed: int = 0 - flash_remaining: int = 0 - flash_on: bool = False - - -@dataclass -class _AlarmHardware: - """Machine state the alarm takes at start and gives back when it closes. - - Both fields are released together in ``on_close()``, so they live together: - the audio routing the alarm rewrote, and the logind inhibitor holding the - power button down while it rings. - """ - - audio_restore: _AudioState | None - inhibitor: subprocess.Popen[bytes] | None = None diff --git a/wake_alarm/_alarm_timers.py b/wake_alarm/_alarm_timers.py deleted file mode 100644 index e0c7340..0000000 --- a/wake_alarm/_alarm_timers.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Timers that run for the whole life of the alarm. - -Split out of ``_alarm.py`` for the 250-line cap, as a mixin rather than free -functions because every one of these reschedules itself through ``root.after`` -and needs the alarm's own view and progress state. - -Note ``_on_skip_window_expired`` deliberately does **not** stop the alarm: only -the workout-skip reward expires. The alarm keeps sounding until the challenge -is actually typed. -""" - -from __future__ import annotations - -import logging -import time -from typing import TYPE_CHECKING - -from wake_alarm._challenges import _make_challenge -from wake_alarm._rounds import ( - NO_SKIP_STATUS, - WINDOW_CLOSED_INFO, - countdown_text, - refresh_interval_ms, - remaining_seconds, - show_challenge, - skip_window_ms, - skip_window_seconds, -) - -if TYPE_CHECKING: # pragma: no cover - from collections.abc import Callable - import tkinter as tk - - from wake_alarm._alarm_state import _AlarmProgress - from wake_alarm._alarm_view import _ViewGroup - from wake_alarm._escape_flow import EscapeHatch - -_logger = logging.getLogger(__name__) - - -class AlarmTimersMixin: - """Self-rescheduling timers for the challenge refresh and skip window.""" - - # Provided by WakeAlarm; declared so type checking sees them. - demo_mode: bool - root: tk.Misc - _active: bool - _progress: _AlarmProgress - _view: _ViewGroup - _escape: EscapeHatch - - # Supplied by AlarmFlashMixin, which WakeAlarm also inherits. Declared - # rather than stubbed: an abstract stub here would win the MRO (this mixin - # is listed first) and shadow the real implementation. - _start_flash_countdown: Callable[[], None] - - def _schedule_code_refresh(self) -> None: - """Replace the current challenge periodically. - - Ensures the user can't simply wait out a hard challenge type — a new - random challenge is generated every DISMISS_CODE_REFRESH_SECONDS. - """ - if not self._active: - return - self._progress.current_challenge = _make_challenge() - show_challenge(self._view, self._progress.current_challenge) - if self._progress.current_challenge.kind == "flash": - self._start_flash_countdown() - self.root.after( - refresh_interval_ms(demo_mode=self.demo_mode), - self._schedule_code_refresh, - ) - - def _schedule_skip_window_close(self) -> None: - """Mark the workout-skip reward as expired after the allowed time.""" - self.root.after( - skip_window_ms(demo_mode=self.demo_mode), - self._on_skip_window_expired, - ) - - 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._progress.skip_earnable = False - self._view.info_label.configure(text=WINDOW_CLOSED_INFO) - self._view.status_label.configure(text=NO_SKIP_STATUS) - _logger.info("Skip window expired - alarm continues until dismissed.") - - def _update_timer(self) -> None: - """Show the skip-window countdown, then a keep-going silence prompt.""" - if not self._active: - return - elapsed = time.monotonic() - self._progress.started_at - remaining = remaining_seconds( - elapsed, - skip_window_seconds(demo_mode=self.demo_mode), - ) - self._view.timer_label.configure( - text=countdown_text( - remaining, - earnable=self._progress.skip_earnable, - ), - ) - self._escape.maybe_offer(elapsed) - self.root.after(1000, self._update_timer) diff --git a/wake_alarm/_alarm_view.py b/wake_alarm/_alarm_view.py deleted file mode 100644 index 13c71e8..0000000 --- a/wake_alarm/_alarm_view.py +++ /dev/null @@ -1,207 +0,0 @@ -"""Widget construction for the wake alarm's dismiss-challenge screen.""" - -from __future__ import annotations - -from dataclasses import dataclass -import tkinter as tk -from typing import TYPE_CHECKING - -from gatelock import bind_activate - -from wake_alarm._constants import DISMISS_ROUNDS_REQUIRED -from wake_alarm._view_proxies import _EntryProxy, _LabelProxy, _ViewGroup - -# Re-imported so existing references keep resolving; __all__ stops ruff -# deleting them as unused. -__all__ = ["_EntryProxy", "_LabelProxy", "_ViewGroup"] - -if TYPE_CHECKING: - from collections.abc import Callable - - from gatelock import LockConfig - - from wake_alarm._challenges import _Challenge - - -@dataclass -class _AlarmLabels: - """Every label on one monitor's challenge screen. - - Grouped rather than spread across :class:`_AlarmView` so that fanning a - ``configure`` call out by name (see :meth:`_ViewGroup._label`) has one - obvious namespace to look in. - """ - - title: tk.Label - round_counter: tk.Label - info: tk.Label - code: tk.Label - status: tk.Label - timer: tk.Label - - -@dataclass -class _AlarmView: - """The Tk widgets that make up the alarm's dismiss-challenge screen. - - ``container`` is placed and never unpacked; ``challenge_frame`` holds - everything the escape hatch has to take off the screen while its form is - up, so hiding and restoring the challenge is one call each way rather - than a per-widget ``pack_forget`` that cannot be undone. - """ - - container: tk.Frame - challenge_frame: tk.Frame - labels: _AlarmLabels - entry: tk.Entry - escape_button: tk.Button - config: LockConfig - output_name: str = "" - - -def _build_challenge( - parent: tk.Misc, - challenge: _Challenge, - config: LockConfig, - on_submit: Callable[[object], None], - entry_var: tk.StringVar, -) -> tuple[_AlarmLabels, tk.Entry]: - """Fill ``parent`` with the challenge itself, top to bottom. - - Split out from :func:`_build_alarm_view` so that neither function has to - hold the whole screen's worth of widgets at once; the pack order here is - the on-screen order, and the entry sits deliberately mid-stack. - """ - title_label = tk.Label( - parent, - text="WAKE UP!", - font=config.font("display", bold=True, scale=1.5), - fg=config.danger, - bg=config.bg, - ) - title_label.pack(pady=config.space("md")) - - round_label = tk.Label( - parent, - text=f"Round 1 / {DISMISS_ROUNDS_REQUIRED}", - font=config.font("title", bold=True), - fg=config.warning, - bg=config.bg, - ) - round_label.pack(pady=config.space("xs")) - - info_label = tk.Label( - parent, - text=challenge.hint, - font=config.font("body"), - fg=config.fg, - bg=config.bg, - ) - info_label.pack(pady=config.space("sm")) - - # Non-flash types use a smaller font because their display text is wider. - # Multiples of the display token rather than raw sizes, so the challenge - # scales with the rest of the screen (and is sized in px, not points). - code_scale = ( - 1.5 if challenge.kind in ("math", "sort", "sequence", "reverse") else 2.25 - ) - code_label = tk.Label( - parent, - text=challenge.display, - font=config.font("display", bold=True, family="Courier", scale=code_scale), - fg=config.success, - bg=config.bg, - ) - code_label.pack(pady=config.space("lg")) - - entry = tk.Entry( - parent, - font=config.font("display", family="Courier"), - justify="center", - width=12, - textvariable=entry_var, - # Tk's default focus ring is 1px BLACK, invisible on bg (#211D1B). This - # field is already focused and Return-bound, so the ring is about - # confirming *where* keystrokes are going -- on a screen with no other - # window and a global grab, that is the only feedback available. - **config.focus_kwargs(), - ) - entry.pack(pady=config.space("sm")) - entry.focus_set() - entry.bind("", on_submit) - - status_label = tk.Label( - parent, - text="", - font=config.font("body"), - fg=config.danger, - bg=config.bg, - ) - status_label.pack(pady=config.space("sm")) - - timer_label = tk.Label( - parent, - text="", - font=config.font("label"), - fg=config.muted, - bg=config.bg, - ) - timer_label.pack(pady=config.space("xs")) - - return ( - _AlarmLabels( - title=title_label, - round_counter=round_label, - info=info_label, - code=code_label, - status=status_label, - timer=timer_label, - ), - entry, - ) - - -def _build_alarm_view( - root: tk.Misc, - challenge: _Challenge, - config: LockConfig, - *, - on_submit: Callable[[object], None], - entry_var: tk.StringVar, -) -> _AlarmView: - """Build the dismiss-challenge UI and return its widgets as a view.""" - container = tk.Frame(root, bg=config.bg) - container.place(relx=0.5, rely=0.5, anchor="center") - - # One frame around the whole challenge: the escape hatch hides it wholesale - # and packs it straight back on cancel. Forgetting the widgets individually - # would be irreversible -- pack_forget does not remember its options. - challenge_frame = tk.Frame(container, bg=config.bg) - challenge_frame.pack() - - labels, entry = _build_challenge( - challenge_frame, challenge, config, on_submit, entry_var - ) - - # Hidden until the alarm has been up long enough; see _escape_flow. - escape_button = tk.Button( - challenge_frame, - text="I can't do this right now", - font=config.font("label"), - bg=config.field_bg, - fg=config.muted, - **config.focus_kwargs(), - ) - # Tk binds only on Button on X11. The escape hatch is deliberately - # hard to reach (hidden until earned, budgeted) but must not additionally be - # hard to *press* once shown. - bind_activate(escape_button) - - return _AlarmView( - container=container, - challenge_frame=challenge_frame, - labels=labels, - entry=entry, - escape_button=escape_button, - config=config, - ) diff --git a/wake_alarm/_audio.py b/wake_alarm/_audio.py deleted file mode 100644 index 2480150..0000000 --- a/wake_alarm/_audio.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Audio playback and PipeWire sink management for the wake alarm.""" - -from __future__ import annotations - -import logging -from pathlib import Path -import shutil -import struct -import subprocess -import sys -import time - -from wake_alarm._sinks import ( - _ensure_sink_audible, - _list_real_sinks, - _pactl_path, -) -from wake_alarm._tone import _ensure_tone_wav - -_logger = logging.getLogger(__name__) - -_TONE_TIMEOUT_SECONDS: float = 6.0 -_TONE_DURATION_SECONDS: float = 1.5 - -# Number of back-to-back PC-speaker beeps per _play_tone call. -# pcspkr volume is hardware-fixed, so we lean on repetition + duration to be -# loud enough to actually wake the user. -_PCSPKR_REPEATS: int = 3 -_PCSPKR_GAP_SECONDS: float = 0.12 - -# Motherboard PC speaker exposed by the pcspkr kernel module. -# Writing EV_SND/SND_TONE input_event structs makes it beep — bypasses -# PipeWire/ALSA entirely, so it stays audible even when no real sink exists. -_PCSPKR_DEVICE: str = "/dev/input/by-path/platform-pcspkr-event-spkr" -_PCSPKR_EV_SND: int = 0x12 -_PCSPKR_SND_TONE: int = 0x02 -# struct input_event: timeval (long sec, long usec), u16 type, u16 code, s32 val -_PCSPKR_EVENT_FMT: str = "llHHi" - - -def _beep_soft() -> None: - """Play a soft system beep via terminal bell.""" - sys.stdout.write("\a") - sys.stdout.flush() - - -def _speaker_test_path() -> str: - """Resolve absolute path to speaker-test binary.""" - path = shutil.which("speaker-test") - if path is None: - msg = "speaker-test not found on PATH" - raise FileNotFoundError(msg) - return path - - -def _beep_pcspkr(frequency: int, duration_seconds: float) -> None: - """Beep the motherboard PC speaker via evdev (audible without any sink). - - Silently no-ops when the device is missing or unwritable so the call is - always safe from the alarm hot path. - """ - try: - # buffering=0 so the write hits the device immediately. - with Path(_PCSPKR_DEVICE).open("wb", buffering=0) as dev: - dev.write( - struct.pack( - _PCSPKR_EVENT_FMT, - 0, - 0, - _PCSPKR_EV_SND, - _PCSPKR_SND_TONE, - int(frequency), - ), - ) - time.sleep(duration_seconds) - dev.write( - struct.pack( - _PCSPKR_EVENT_FMT, - 0, - 0, - _PCSPKR_EV_SND, - _PCSPKR_SND_TONE, - 0, - ), - ) - except OSError: - _logger.warning( - "PC speaker beep at %d Hz failed (device %s)", - frequency, - _PCSPKR_DEVICE, - exc_info=True, - ) - - -def _try_player(binary: str, wav: Path) -> bool: - """Run *binary* on *wav* with a generous timeout. Return True on success.""" - path = shutil.which(binary) - if path is None: - return False - try: - result = subprocess.run( - [path, str(wav)], - capture_output=True, - timeout=_TONE_TIMEOUT_SECONDS, - check=False, - ) - except OSError, subprocess.TimeoutExpired: - _logger.warning("%s failed playing %s", binary, wav.name, exc_info=True) - return False - if result.returncode != 0: - _logger.warning( - "%s exited %d for %s: %s", - binary, - result.returncode, - wav.name, - result.stderr.decode(errors="replace").strip()[:200], - ) - return False - return True - - -def _play_tone(frequency: int) -> None: - """Play a sine tone via paplay/aplay/speaker-test, fall back to soft beep. - - Always also beeps the motherboard PC speaker (multiple times) so the - alarm stays loud and audible even when PipeWire only has the auto_null - sink. - """ - for i in range(_PCSPKR_REPEATS): - _beep_pcspkr(frequency, _TONE_DURATION_SECONDS) - if i < _PCSPKR_REPEATS - 1: - time.sleep(_PCSPKR_GAP_SECONDS) - try: - wav = _ensure_tone_wav(frequency) - except OSError: - _logger.warning( - "Could not generate tone WAV at %d Hz; using soft beep", - frequency, - exc_info=True, - ) - _beep_soft() - return - for binary in ("paplay", "aplay"): - if _try_player(binary, wav): - return - try: - subprocess.run( - [ - _speaker_test_path(), - "-t", - "sine", - "-f", - str(frequency), - "-l", - "1", - ], - capture_output=True, - timeout=_TONE_TIMEOUT_SECONDS, - check=False, - ) - except FileNotFoundError, OSError, subprocess.TimeoutExpired: - _logger.warning( - "All tone players failed at %d Hz; falling back to soft beep", - frequency, - exc_info=True, - ) - _beep_soft() - - -def _play_on_all_sinks(frequency: int) -> None: - """Play the tone WAV on every real PipeWire sink, simultaneously. - - Re-enumerates sinks on every call so outputs that appear late (the G27Q - HDMI sink can take ~15 s to power on) are automatically included without - re-running activation. All sinks are launched concurrently, then reaped so - the 30-minute alarm loop does not accumulate zombie ``paplay`` children. - """ - pactl = _pactl_path() - if pactl is None: - return - paplay = shutil.which("paplay") - if paplay is None: - _logger.warning("paplay not on PATH; skipping per-sink alarm playback") - return - try: - wav = _ensure_tone_wav(frequency) - except OSError: - _logger.warning( - "Could not generate tone WAV at %d Hz for per-sink playback", - frequency, - exc_info=True, - ) - return - procs: list[subprocess.Popen[bytes]] = [] - for sink in _list_real_sinks(pactl): - # Re-assert audibility every cycle: a source that is muted, low, or only - # just appeared (a monitor powering on mid-alarm) is made audible before - # we play on it — we never bet on one sink staying the chosen output. - _ensure_sink_audible(pactl, sink) - proc = _play_wav_on_sink(paplay, sink, wav) - if proc is not None: - procs.append(proc) - _reap_players(procs) - - -def _play_wav_on_sink( - paplay: str, - sink: str, - wav: Path, -) -> subprocess.Popen[bytes] | None: - """Launch ``paplay --device=`` for *wav*; return the process or None.""" - try: - return subprocess.Popen( - [paplay, f"--device={sink}", str(wav)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except OSError: - _logger.warning("Failed to play tone on sink %s", sink, exc_info=True) - return None - - -def _reap_players(procs: list[subprocess.Popen[bytes]]) -> None: - """Wait for each concurrent paplay to finish, killing any that hang. - - The tone is ~1.5 s, so all launched players finish quickly and are reaped - here rather than left as ```` children for the alarm's lifetime. - """ - for proc in procs: - _reap_player(proc) - - -def _reap_player(proc: subprocess.Popen[bytes]) -> None: - """Wait for one paplay process, killing and reaping it if it hangs.""" - try: - proc.wait(timeout=_TONE_TIMEOUT_SECONDS) - except subprocess.TimeoutExpired: - _logger.warning("paplay hung; killing pid %d", proc.pid) - proc.kill() - proc.wait() - - -def _beep_medium(frequency: int = 1000) -> None: - """Play a medium beep (sine tone via paplay/aplay/speaker-test).""" - _play_tone(frequency) - - -def _beep_loud(frequency: int = 1000) -> None: - """Play a loud sine tone via paplay/aplay/speaker-test.""" - _play_tone(frequency) diff --git a/wake_alarm/_beep_loop.py b/wake_alarm/_beep_loop.py deleted file mode 100644 index be6793c..0000000 --- a/wake_alarm/_beep_loop.py +++ /dev/null @@ -1,99 +0,0 @@ -"""The background thread that sounds the alarm. - -Split out of ``_alarm.py`` so the loop can be exercised without a Tk window, -and so the escalation decision itself stays in ``_escalation`` as a pure -function. - -The loop plays on **every** real sink each cycle rather than choosing one -output: a speaker that is switched off cannot be revived by software, so the -answer is to drive every other output in parallel. -""" - -from __future__ import annotations - -from collections.abc import Callable -import logging -import threading -import time - -from wake_alarm._audio import ( - _beep_loud, - _beep_medium, - _beep_soft, - _play_on_all_sinks, -) -from wake_alarm._escalation import BeepPlan, plan_for - -_Monotonic = Callable[[], float] - -_logger = logging.getLogger(__name__) - - -def _sound(plan: BeepPlan) -> None: - """Emit one cycle of audio for *plan*. - - Args: - plan: The decision for this iteration. - """ - if plan.frequency is None: - return - _play_on_all_sinks(plan.frequency) - if plan.phase == "soft": - _beep_soft() - elif plan.phase == "medium": - _beep_medium() - else: - _beep_loud(plan.frequency) - - -def run_beep_loop( - stop: threading.Event, - started_at: float, - *, - monotonic: _Monotonic = time.monotonic, -) -> None: - """Sound the alarm until *stop* is set. - - Args: - stop: Set on dismissal (and by the caller's exit paths). - started_at: Monotonic timestamp when the ring began. - monotonic: Clock source, injected for tests. - - The failsafe is measured from this side's own monotonic clock, never from - a timestamp compared across devices: a skewed phone clock must not be able - to silence the PC early, nor keep it sounding forever. - """ - announced_silence = False - while not stop.is_set(): - elapsed = monotonic() - started_at - plan = plan_for(elapsed) - if plan.silenced and not announced_silence: - announced_silence = True - _logger.warning( - "Failsafe reached after %.0fs: audio stops, but the lock and " - "challenge remain until dismissed.", - elapsed, - extra={"event": "failsafe_silence", "elapsed_s": round(elapsed)}, - ) - _sound(plan) - stop.wait(plan.interval) - - -def start_beep_thread(stop: threading.Event, started_at: float) -> threading.Thread: - """Run :func:`run_beep_loop` on a daemon thread. - - Args: - stop: Set on dismissal. - started_at: Monotonic timestamp when the ring began. - - Returns: - The started thread. - """ - thread = threading.Thread( - target=run_beep_loop, - args=(stop, started_at), - daemon=True, - name="wake-alarm-beep", - ) - thread.start() - return thread diff --git a/wake_alarm/_brightness.py b/wake_alarm/_brightness.py deleted file mode 100644 index 365767b..0000000 --- a/wake_alarm/_brightness.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Raise every monitor to full brightness when the alarm fires. - -Split out of ``_audio.py`` for the 250-line cap; it is display control, not -audio, and only ever lived there for historical reasons. -""" - -from __future__ import annotations - -import logging -import shutil -import subprocess - -_logger = logging.getLogger(__name__) - - -def _set_max_brightness() -> None: - """Set all connected monitors to maximum brightness via xrandr.""" - xrandr = shutil.which("xrandr") - if xrandr is None: - _logger.warning("xrandr not on PATH; skipping max-brightness") - return - try: - result = subprocess.run( - [xrandr, "--query"], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - except OSError, subprocess.TimeoutExpired: - _logger.warning("xrandr --query failed; skipping max-brightness", exc_info=True) - return - for line in result.stdout.splitlines(): - if " connected" in line: - output = line.split()[0] - try: - subprocess.run( - [xrandr, "--output", output, "--brightness", "1.0"], - check=False, - capture_output=True, - timeout=5, - ) - except OSError, subprocess.TimeoutExpired: - _logger.warning( - "Failed to set brightness on %s", - output, - exc_info=True, - ) diff --git a/wake_alarm/_challenges.py b/wake_alarm/_challenges.py deleted file mode 100644 index fa0b61d..0000000 --- a/wake_alarm/_challenges.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Dismiss-challenge types for the wake alarm. - -Provides five challenge variants: -- math: solve an arithmetic problem (addition, subtraction, multiplication, division) -- sort: type shuffled digits in ascending order -- flash: memorise a code before it is hidden -- sequence: identify and continue a numeric pattern -- reverse: type a displayed word backwards -""" - -from __future__ import annotations - -import secrets - -from wake_alarm._constants import DISMISS_CODE_LENGTH, DISMISS_FLASH_SECONDS - -# Uppercase alphanumeric chars with visually ambiguous characters removed: -# O/0 (oh vs zero) and I/1 (capital-i vs one) are excluded so the code is -# legible at a glance, even half-asleep. -_DISMISS_CHARS: str = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" - -# Words used by the reverse challenge — common, unambiguous when read half-asleep. -_REVERSE_WORDS: tuple[str, ...] = ( - "MORNING", - "COFFEE", - "PILLOW", - "BLANKET", - "ALARM", - "AWAKE", - "SHOWER", - "BREAKFAST", - "WORKOUT", - "WINDOW", - "MONDAY", - "TUESDAY", - "FRIDAY", - "SUNDAY", - "WEEKEND", -) - - -class _Challenge: - """A dismiss challenge presented to the user to prove wakefulness.""" - - def __init__( - self, - *, - kind: str, - display: str, - answer: str, - hint: str, - ) -> None: - """Store challenge parameters. - - Args: - kind: Challenge type — "math", "flash", "sort", "sequence", or "reverse". - display: Text shown in the large code label. - answer: Expected typed answer (normalised, upper-case). - hint: Short instruction shown above the code label. - """ - self.kind: str = kind - self.display: str = display - self.answer: str = answer - self.hint: str = hint - - -def _generate_code() -> str: - """Generate a random alphanumeric dismiss code. - - Uses uppercase letters and digits only, with ambiguous characters - (O, I, 0, 1) removed so the displayed code is easy to read at a glance. - """ - return "".join(secrets.choice(_DISMISS_CHARS) for _ in range(DISMISS_CODE_LENGTH)) - - -def _make_math_challenge() -> _Challenge: - """Generate an arithmetic problem the user must solve to dismiss. - - Picks randomly from addition, subtraction, multiplication, and division. - Addition and subtraction use 3-digit operands for extra difficulty. - Division always produces an integer result. - """ - op = secrets.choice(("+", "-", "*", "÷")) - if op == "+": - a = 100 + secrets.randbelow(400) - b = 100 + secrets.randbelow(400) - return _Challenge( - kind="math", - display=f"{a} + {b} = ?", - answer=str(a + b), - hint="Solve and type the answer", - ) - if op == "-": - a = 200 + secrets.randbelow(300) - b = 100 + secrets.randbelow(a - 100) - return _Challenge( - kind="math", - display=f"{a} - {b} = ?", - answer=str(a - b), - hint="Solve and type the answer", - ) - if op == "*": - a = 12 + secrets.randbelow(14) - b = 3 + secrets.randbelow(7) - return _Challenge( - kind="math", - display=f"{a} * {b} = ?", - answer=str(a * b), - hint="Solve and type the answer", - ) - # Division: construct a * b first so the quotient is always an integer. - b = 3 + secrets.randbelow(8) # divisor: 3-10 - a = (10 + secrets.randbelow(20)) * b # dividend: b * (10-29) - return _Challenge( - kind="math", - display=f"{a} ÷ {b} = ?", - answer=str(a // b), - hint="Solve and type the answer", - ) - - -def _make_sort_challenge() -> _Challenge: - """Generate a sort-the-digits challenge. - - Displays eight shuffled single digits; the user types them ascending (no spaces). - Eight digits (up from six) require more effort to sort mentally. - """ - pool = list(range(1, 10)) - for i in range(len(pool) - 1, 0, -1): - j = secrets.randbelow(i + 1) - pool[i], pool[j] = pool[j], pool[i] - digits = pool[:8] - display = " ".join(str(d) for d in digits) - answer = "".join(str(d) for d in sorted(digits)) - return _Challenge( - kind="sort", - display=display, - answer=answer, - hint="Type digits sorted lowest → highest (no spaces)", - ) - - -def _make_flash_challenge() -> _Challenge: - """Generate a memorise-then-type challenge. - - Shows a code for DISMISS_FLASH_SECONDS, then hides it. - The user must type the code from memory. - """ - code = _generate_code() - return _Challenge( - kind="flash", - display=code, - answer=code, - hint=f"Memorise this code — it disappears in {DISMISS_FLASH_SECONDS}s", - ) - - -def _make_sequence_challenge() -> _Challenge: - """Generate a numeric-pattern completion challenge. - - Shows four terms of an arithmetic or geometric sequence and asks for the fifth. - Forces the user to identify the pattern before they can answer. - """ - if secrets.randbelow(2) == 0: - # Arithmetic sequence (linear) - start = 2 + secrets.randbelow(14) # start: 2-15 - step = 2 + secrets.randbelow(8) # step: 2-9 - terms = [start + step * i for i in range(4)] - answer = start + step * 4 - display = ", ".join(str(t) for t in terms) + ", ?" - return _Challenge( - kind="sequence", - display=display, - answer=str(answer), - hint="Find the pattern and type the next number", - ) - # Geometric sequence (exponential) - start = 2 + secrets.randbelow(5) # start: 2-6 - ratio = 2 + secrets.randbelow(2) # ratio: 2 or 3 - terms = [start * (ratio**i) for i in range(4)] - answer = start * (ratio**4) - display = ", ".join(str(t) for t in terms) + ", ?" - return _Challenge( - kind="sequence", - display=display, - answer=str(answer), - hint="Find the pattern and type the next number", - ) - - -def _make_reverse_challenge() -> _Challenge: - """Generate a reverse-the-word challenge. - - Picks a common word and asks the user to type it backwards. - Simple but demands conscious attention — autopilot types forward. - """ - word = secrets.choice(_REVERSE_WORDS) - return _Challenge( - kind="reverse", - display=f"Type this word backwards:\n{word}", - answer=word[::-1], - hint="Type the word backwards", - ) - - -def _make_challenge() -> _Challenge: - """Pick a random challenge type and generate an instance.""" - return secrets.choice( - ( - _make_math_challenge, - _make_flash_challenge, - _make_sort_challenge, - _make_sequence_challenge, - _make_reverse_challenge, - ), - )() diff --git a/wake_alarm/_constants.py b/wake_alarm/_constants.py index 41836da..e94802b 100644 --- a/wake_alarm/_constants.py +++ b/wake_alarm/_constants.py @@ -1,4 +1,9 @@ -"""Constants for the weekend wake alarm system.""" +"""Constants for the wake-alarm trigger and the phone-alarm sync. + +The PC no longer wakes anyone -- the phone does. What is left here is what +the per-minute trigger needs to answer two questions: what time did the phone +say, and has the morning routine already started today. +""" from __future__ import annotations @@ -8,14 +13,6 @@ from pathlib import Path # Every day of the week. ALARM_DAYS: frozenset[int] = frozenset({0, 1, 2, 3, 4, 5, 6}) -# How many hours after shutdown the PC should wake. -# Only used by the hibernate fallback path (shutdown-wrapper.sh) for the rare -# night the PC is actually powered down; the primary path is the per-minute -# trigger timer, which reads the phone's alarm directly (see _trigger.py). -WAKE_AFTER_HOURS: int = 8 - -# Minutes after alarm starts within which you must dismiss to earn skip -DISMISS_WINDOW_MINUTES: int = 30 # Hour at which the alarm fires when the phone's alarm cannot be resolved at # all — no network *and* no cached read ever written. Minute is implicitly 0. @@ -26,129 +23,16 @@ DISMISS_WINDOW_MINUTES: int = 30 # the one direction the whole design refuses to allow. FALLBACK_ALARM_HOUR: int = 8 -# Alarm escalation phase boundaries (minutes from alarm start). -# -# Compressed to fit INSIDE the 5-minute failsafe window. They used to be 5 and -# 15, which -- once the failsafe was added at 300 s -- meant the alarm was -# silenced before it ever left the softest rung, and the medium and loud -# phases became unreachable code. The alarm has to reach its most annoying -# setting well before it goes quiet, or waiting out five minutes in bed is a -# winning strategy. -PHASE_SOFT_END: int = 1 -PHASE_MEDIUM_END: int = 2 -# After PHASE_MEDIUM_END: alternating loud tones until the failsafe silences. - -# Beep intervals per phase (seconds) -SOFT_BEEP_INTERVAL: float = 5.0 -MEDIUM_BEEP_INTERVAL: float = 3.0 -LOUD_TOGGLE_INTERVAL: float = 2.0 - -# Dismiss challenge: length of the random code -DISMISS_CODE_LENGTH: int = 10 -# Number of correct code entries required to dismiss the alarm. -# Requiring more than one round forces the user to stay awake long enough -# to actually read and type multiple independent codes. -DISMISS_ROUNDS_REQUIRED: int = 4 -# Seconds the code is visible before being hidden in a flash challenge. -DISMISS_FLASH_SECONDS: int = 4 -# How often the dismiss code refreshes (seconds) -DISMISS_CODE_REFRESH_SECONDS: int = 30 # State file for wake alarm (HMAC-signed) WAKE_STATE_FILE: Path = Path(__file__).resolve().parent / "wake_state.json" -# rtcwake binary path -RTCWAKE_BIN: str = "/usr/sbin/rtcwake" - -# Preferred alarm audio output (machine-specific, empirically verified). -# At wake time the Bluetooth speaker is disconnected and PipeWire often has only -# the auto_null sink, so the alarm activates every card and plays on all sinks. -# This is the G27Q monitor's built-in HDMI speaker — the one always-present -# output on this machine — used as the preferred default sink when present. -ALARM_AUDIO_SINK: str = "alsa_output.pci-0000_01_00.1.hdmi-stereo" - -# Path to the workout log written by the companion screen_locker package. -# Dict keyed by YYYY-MM-DD date strings; presence of today's key means the -# workout was already completed and the alarm should not fire. -WORKOUT_LOG_FILE: Path = ( - Path.home() / "screen-locker" / "screen_locker" / "workout_log.json" -) # --- Phone alarm sync (crdt_sync over GitHub) ----------------------------- # The phone app pushes its alarm to a private repo as a crdt_sync log; the PC # reads it back. shutdown-wrapper.sh reads the SAME file with jq for the # hibernate fallback — keep the two readers in step if any of this changes. -# GitHub token config, key "token" (PAT with "repo" scope: the repo is private). -# The alarm's escape-hatch history. Lives beside the sync config rather -# than in the package dir, so a reinstall cannot silently reset the budget. -ESCAPE_HISTORY_FILE: Path = ( - Path.home() / ".config" / "wake_alarm" / "escape_history.json" -) -# How long the alarm must have been up before the hatch is offered, so it -# is never the easy first move. -# -# Deliberately NOT equal to FAILSAFE_SILENCE_SECONDS. Both used to sit at 300, -# which meant the sound stopped and the escape hatch appeared in the same -# instant -- making the hatch far more attractive than it was designed to be, -# exactly when the alarm had just stopped being annoying. -ESCAPE_OFFER_AFTER_SECONDS: int = 900 - -# Pause between powering the smart plug and the first beep. A powered -# speaker's soft-start / mute relay typically delays output 1-5 s after mains -# is applied, so firing the plug and the first tone together loses the opening -# of the alarm. Costs nothing when no plug is configured. -PLUG_PREROLL_SECONDS: float = 4.0 - -# The fire-time session record. Beside the sync config rather than in the -# package dir, so a reinstall cannot erase evidence that an alarm was cut -# short. Distinct from WAKE_STATE_FILE, which records only *dismissal* and is -# also read cross-repo by screen_locker/_wake_state.py. -ALARM_SESSION_FILE: Path = Path.home() / ".config" / "wake_alarm" / "alarm_session.json" - -# How long after fire time an unfinished session still re-fires the alarm. -# Bounded because nothing clears the record when the alarm process dies before -# dismissal: unbounded, a crash at startup would restart the alarm every minute -# forever. Long enough to survive a power-off plus a boot, short enough that a -# session can never leak into the following night. -REFIRE_MAX_AGE_SECONDS: float = 5400.0 - -# How far a session record may be stamped in the *future* and still count as -# ringing. A backward clock jump (NTP correcting after a resume, RTC skew at -# boot) is routine on a machine that hibernates nightly, and discarding such a -# record would fail open on exactly the power-off escape the record closes. -# Bounded so a wildly wrong RTC still cannot create an endless re-fire. -CLOCK_SKEW_TOLERANCE_SECONDS: float = 900.0 - -# --- Power-button inhibitor (item 2) -------------------------------------- -# What logind is asked to block while the alarm rings. handle-power-key stops -# the short press; shutdown and idle stop a scripted poweroff and the idle -# timer from doing the same job by another route. -INHIBIT_WHAT: str = "handle-power-key:shutdown:idle" - -# The --who string. Also the pkill pattern used to clear a stale blocker from a -# crashed alarm, so it must stay distinctive. -INHIBIT_WHO: str = "wake-alarm" -INHIBIT_WHY: str = "Wake alarm is ringing and must be dismissed, not switched off" - -# The inhibitor child self-terminates after this long even if nobody releases -# it. An orphaned block-mode inhibitor holding handle-power-key would leave the -# power button dead until reboot -- a worse failure than the one being fixed -- -# so it is never `sleep infinity`. Comfortably longer than any real alarm. -INHIBIT_MAX_SECONDS: float = 7200.0 - -# How long to wait for the inhibitor child to prove it survived. Popen -# succeeding only means the binary launched; logind can still refuse (polkit -# denies a non-interactive session, seen in a vmbox run: "Failed to inhibit: -# Access denied", exit 1). Still alive after this means the inhibit was -# accepted. Short enough not to delay the alarm perceptibly. -INHIBIT_CONFIRM_SECONDS: float = 0.5 - -# When the failsafe silences the alarm. Audio ONLY: the lock, the typed -# challenge and the workout gate all persist past this point, so waiting it -# out in bed is not a way to escape the alarm. It exists so a ring nobody is -# present to dismiss does not sound indefinitely. -FAILSAFE_SILENCE_SECONDS: float = 300.0 SYNC_CONFIG_FILE: Path = Path.home() / ".config" / "wake_alarm" / "sync.json" @@ -165,12 +49,6 @@ SYNC_TOKEN_FALLBACK_FILES: tuple[Path, ...] = ( # Where a successful read is cached, so a network blip can't cost a wake-up. ALARM_CACHE_FILE: Path = Path.home() / ".config" / "wake_alarm" / "alarm_cache.json" -# Delays between card-profile reactivation passes while the alarm rings. -# The G27Q's HDMI sink takes ~15 s to enumerate after the monitor wakes, and -# until its card profile is switched on the sink does not exist at all -- so -# re-enumerating sinks alone can never find it. Decaying schedule: dense early -# (when outputs are still arriving), sparse later, stopping at ~2 minutes. -REACTIVATE_ATTEMPT_DELAYS: tuple[float, ...] = (3.0, 5.0, 7.0, 10.0, 15.0, 20.0, 30.0) # Structured logs live under XDG state, not .config: they are churning runtime # data, not configuration. The JSONL file is additive -- the journal keeps @@ -237,14 +115,3 @@ TRIGGER_CATCHUP_MINUTES: int = 60 # wake-alarm.service directly: the orchestrator runs the alarm first and the # workout lock after, so the two never fight for the fullscreen. TRIGGER_TARGET_UNIT: str = "morning-routine.service" - -# 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 -> -# "tapo@example.com" and password -> "your-password". -# Missing/invalid file => smart-plug control is skipped silently. -TAPO_CONFIG_FILE: Path = Path.home() / ".config" / "wake_alarm" / "tapo.json" - -# Timeout (seconds) for a single Tapo plug operation. Keep short so a -# missing/unreachable plug never delays the alarm by more than this. -TAPO_TIMEOUT_SECONDS: float = 5.0 diff --git a/wake_alarm/_correlation.py b/wake_alarm/_correlation.py deleted file mode 100644 index b88b273..0000000 --- a/wake_alarm/_correlation.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Derive the identifier that joins one morning's PC and phone log lines. - -The id is **derived, never generated**: both halves compute it independently -from the resolved alarm instant, so no handshake, no uuid exchange and no -network round-trip is involved. That matters because the two devices must -still agree when sync is down -- which is exactly when the logs are worth -reading. - -Local time, matching ``_trigger``'s local-time basis (``_state.py`` keys by -UTC, which is a separate known mismatch tracked in the hardening plan). -""" - -from __future__ import annotations - -from datetime import datetime - -from wake_alarm._sync import get_alarm - -# ``wake-YYYYMMDD-HHMM``. Stable across processes and across devices. -_CORRELATION_FORMAT = "wake-%Y%m%d-%H%M" - - -def correlation_id(fire_time: datetime) -> str: - """Build the correlation id for the alarm firing at *fire_time*. - - Args: - fire_time: The resolved local fire instant for today's alarm. - - Returns: - A string of the form ``wake-20260826-0700``. - """ - return fire_time.strftime(_CORRELATION_FORMAT) - - -def correlation_id_for(hour: int, minute: int, *, now: datetime) -> str: - """Build the correlation id from an ``(hour, minute)`` alarm on *now*'s date. - - Convenience for callers that hold the synced alarm rather than a resolved - datetime. The date comes from *now*, so a caller must pass the date the - alarm belongs to, not merely the current instant. - - Args: - hour: Alarm hour, 0-23. - minute: Alarm minute, 0-59. - now: Any datetime on the alarm's local date. - - Returns: - The correlation id string. - """ - return correlation_id(now.replace(hour=hour, minute=minute)) - - -def correlation_id_for_today() -> str: - """Derive this morning's correlation id from the *resolved alarm time*. - - Not from "now": the alarm can start up to a full catch-up window late, and - a re-fire after a power-off can start hours later still. The phone derives - its id from the alarm time it was set to, with no handshake between the - two, so deriving the PC's from the start instant would silently stop the - two log streams joining -- ``wake-20260827-0735`` against - ``wake-20260827-0700``. - - Returns: - The correlation id both halves compute independently. - """ - alarm = get_alarm() - return correlation_id_for(alarm.hour, alarm.minute, now=datetime.now().astimezone()) diff --git a/wake_alarm/_countdown.py b/wake_alarm/_countdown.py deleted file mode 100644 index 8e748ab..0000000 --- a/wake_alarm/_countdown.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Format the skip-window countdown shown on the lock screen. - -Split out of ``_alarm.py`` for the 250-line cap, and because the wording is -load-bearing: once the window closes the label must stop implying a reward is -still available while making clear the alarm has *not* stopped. -""" - -from __future__ import annotations - -from wake_alarm._constants import DISMISS_CODE_REFRESH_SECONDS, DISMISS_WINDOW_MINUTES - -# Demo mode compresses the schedule so a manual test does not take half an -# hour, without changing any production timing. -DEMO_REFRESH_MS = 10_000 -DEMO_WINDOW_MS = 30_000 -DEMO_WINDOW_SECONDS = 30.0 - -NO_SKIP_TEXT = "No skip available - type the code to stop the alarm" -WINDOW_CLOSED_INFO = "Skip window closed - type the code to stop the alarm" -NO_SKIP_STATUS = "No workout skip today." - - -def remaining_seconds(elapsed: float, window: float) -> float: - """Seconds left in the skip window, never negative. - - Args: - elapsed: Seconds since the alarm started. - window: Total length of the skip window in seconds. - - Returns: - Remaining seconds, floored at zero. - """ - return max(0.0, window - elapsed) - - -def countdown_text(remaining: float, *, earnable: bool) -> str: - """Build the timer label for the current state. - - Args: - remaining: Seconds left in the skip window. - earnable: Whether a skip can still be earned. - - Returns: - The label text. - """ - if not earnable or remaining <= 0: - return NO_SKIP_TEXT - minutes = int(remaining) // 60 - seconds = int(remaining) % 60 - return f"Skip window: {minutes:02d}:{seconds:02d}" - - -def refresh_interval_ms(*, demo_mode: bool) -> int: - """Milliseconds between challenge refreshes. - - Args: - demo_mode: True for the shortened manual-test schedule. - - Returns: - The delay to pass to ``after()``. - """ - if demo_mode: - return DEMO_REFRESH_MS - return DISMISS_CODE_REFRESH_SECONDS * 1000 - - -def skip_window_ms(*, demo_mode: bool) -> int: - """Milliseconds until the workout-skip reward expires. - - Args: - demo_mode: True for the shortened manual-test schedule. - - Returns: - The delay to pass to ``after()``. - """ - if demo_mode: - return DEMO_WINDOW_MS - return DISMISS_WINDOW_MINUTES * 60 * 1000 - - -def skip_window_seconds(*, demo_mode: bool) -> float: - """Length of the skip window in seconds. - - Args: - demo_mode: True for the shortened manual-test schedule. - - Returns: - The window length. - """ - if demo_mode: - return DEMO_WINDOW_SECONDS - return float(DISMISS_WINDOW_MINUTES * 60) diff --git a/wake_alarm/_curtains.py b/wake_alarm/_curtains.py deleted file mode 100644 index 75575dd..0000000 --- a/wake_alarm/_curtains.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Open the curtains when the alarm fires, if any hardware is configured. - -Item 5. There is **no curtain hardware yet**, so the default driver is a -logging no-op: it records what it would have done and returns. That is the -whole point of shipping this now rather than waiting for a motor — the alarm -sequence gains its call site, the JSONL log gains its event, and buying -hardware later is a config file, not a code change. - -Deliberately shaped like :mod:`wake_alarm._smart_plug`, which solves the same -problem (optional hardware that must never delay or break the alarm): - -* absent or malformed config means "no curtains", never an error; -* every failure is caught and logged, because a stuck motor must not stop the - alarm from ringing; -* a driver's dependency is import-guarded, so an uninstalled library degrades - to the no-op instead of killing the alarm process. - -Config lives at ``~/.config/wake_alarm/curtains.json``:: - - {"driver": "noop"} - -``DOCS-curtains-hardware.md`` has the buy list and what each driver needs. -""" - -from __future__ import annotations - -import json -import logging -from pathlib import Path -from typing import TYPE_CHECKING, Protocol - -if TYPE_CHECKING: - from collections.abc import Callable - -_logger = logging.getLogger(__name__) - -# Kept here rather than in _constants.py because this module is their only -# consumer -- the same call the repo already makes for _rounds.py's timing -# constants and _alarm_session.py's state names. -CURTAINS_CONFIG_FILE = Path.home() / ".config" / "wake_alarm" / "curtains.json" -"""Which motor to drive, if any. Absent => the no-op driver.""" - -CURTAINS_DEFAULT_DRIVER = "noop" -"""Used when nothing is configured, and when a config names an unknown one.""" - - -class CurtainDriver(Protocol): - """What any curtain implementation has to provide. - - Two methods, both of which must return rather than raise: the alarm calls - them on its startup path, and a driver that throws would take the whole - alarm down with it. - """ - - def open(self) -> bool: - """Open the curtains. Returns True when the hardware acknowledged.""" - - def close(self) -> bool: - """Close the curtains. Returns True when the hardware acknowledged.""" - - -class NoOpCurtains: - """The default: log the intent, touch nothing. - - Not a placeholder to be replaced later so much as the correct behaviour on - a machine with no curtain motor. It keeps the call site and the log event - real, so the day hardware arrives the only change is a config file. - """ - - name = "noop" - - def open(self) -> bool: - """Record that the curtains would have opened.""" - _logger.info( - "Curtains: would open now (no hardware configured)", - extra={"event": "curtains_open", "driver": self.name, "real": False}, - ) - return True - - def close(self) -> bool: - """Record that the curtains would have closed.""" - _logger.info( - "Curtains: would close now (no hardware configured)", - extra={"event": "curtains_close", "driver": self.name, "real": False}, - ) - return True - - -# Driver registry. A new motor means adding one entry and one class, with its -# dependency import-guarded inside the factory so an uninstalled library -# degrades to the no-op rather than killing the alarm. -_DRIVERS: dict[str, Callable[[], CurtainDriver]] = { - NoOpCurtains.name: NoOpCurtains, -} - - -def _load_config() -> dict[str, object] | None: - """Return the curtain config, or None when there is none to read. - - Returns: - The parsed config dict, or None when the file is absent, unreadable, - or not a JSON object. Every one of those means "no curtains", which is - the correct state on a machine that has none. - """ - if not CURTAINS_CONFIG_FILE.exists(): - return None - try: - with CURTAINS_CONFIG_FILE.open() as handle: - config = json.load(handle) - except OSError, json.JSONDecodeError: - _logger.warning( - "Cannot read %s; falling back to the no-op curtain driver", - CURTAINS_CONFIG_FILE, - ) - return None - if not isinstance(config, dict): - _logger.warning("Curtain config is not a JSON object; using the no-op driver") - return None - return config - - -def get_driver() -> CurtainDriver: - """Build the configured curtain driver, or the no-op when none applies. - - Returns: - A driver honouring :class:`CurtainDriver`. Never None and never - raising: the alarm calls this on its startup path. - """ - config = _load_config() - name = CURTAINS_DEFAULT_DRIVER - if config is not None: - requested = config.get("driver") - if isinstance(requested, str): - name = requested - factory = _DRIVERS.get(name) - if factory is None: - _logger.warning( - "Unknown curtain driver %r; using %s. Known drivers: %s", - name, - CURTAINS_DEFAULT_DRIVER, - ", ".join(sorted(_DRIVERS)), - ) - factory = _DRIVERS[CURTAINS_DEFAULT_DRIVER] - return factory() - - -def open_curtains() -> bool: - """Open the curtains as part of the alarm's wake sequence. - - Sunlight is the one wake cue that keeps working after the sound stops at - ``FAILSAFE_SILENCE_SECONDS``, which is why this is worth a call site even - with no motor attached yet. - - Returns: - True when a driver reported success. A failure is logged and - swallowed — a jammed curtain must never stop the alarm ringing. - """ - try: - return get_driver().open() - except OSError, RuntimeError, ValueError: - _logger.warning( - "Curtain driver failed to open; the alarm continues", - exc_info=True, - extra={"event": "curtains_failed"}, - ) - return False diff --git a/wake_alarm/_dismiss_view.py b/wake_alarm/_dismiss_view.py deleted file mode 100644 index bbd1371..0000000 --- a/wake_alarm/_dismiss_view.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Render the post-dismissal banner across every monitor. - -Split out of ``_alarm.py`` for the 250-line cap. Pure presentation: deciding -*whether* a skip was earned stays in the alarm, this only draws the outcome. -""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Iterable - - from gatelock import LockConfig - -SKIP_EARNED_MESSAGE = "Workout skip earned! Enjoy your morning." -NO_SKIP_MESSAGE = "Alarm dismissed. No workout skip." - -# How long the banner stays up before the lock closes itself. -BANNER_LINGER_MS = 3000 - - -def clear_surfaces(containers: Iterable[tk.Misc]) -> None: - """Remove every child widget from each per-output container. - - Args: - containers: Per-output container widgets. - """ - for container in containers: - for widget in container.winfo_children(): - widget.destroy() - - -def show_dismissal( - containers: Iterable[tk.Misc], - config: LockConfig, - *, - earned_skip: bool, -) -> None: - """Replace the challenge UI with the outcome message on every monitor. - - Args: - containers: Per-output container widgets. - config: Styling source for fonts, colours and spacing. - earned_skip: Whether the workout skip was earned. - """ - clear_surfaces(containers) - message = SKIP_EARNED_MESSAGE if earned_skip else NO_SKIP_MESSAGE - colour = config.success if earned_skip else config.warning - for container in containers: - tk.Label( - container, - text=message, - font=config.font("display", bold=True), - fg=colour, - bg=config.bg, - ).pack(pady=config.space("lg")) diff --git a/wake_alarm/_escalation.py b/wake_alarm/_escalation.py deleted file mode 100644 index bd5aefe..0000000 --- a/wake_alarm/_escalation.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Decide what the alarm should sound like at a given point in the ring. - -Split out of ``_alarm.py`` so the escalation ladder -- and, more importantly, -the failsafe that silences it -- is testable without a Tk window, a lock, or a -sound card. - -The failsafe silences **audio only**. The lock, the typed challenge and the -workout gate all persist: a five-minute wait must not become a way to get back -into bed. What it protects against is a runaway alarm nobody is present to -dismiss (an empty flat, a forgotten travel day), which is a neighbour problem, -not a wake-up problem. -""" - -from __future__ import annotations - -from dataclasses import dataclass - -from wake_alarm._constants import ( - FAILSAFE_SILENCE_SECONDS, - LOUD_TOGGLE_INTERVAL, - MEDIUM_BEEP_INTERVAL, - PHASE_MEDIUM_END, - PHASE_SOFT_END, - SOFT_BEEP_INTERVAL, -) - -# Tone frequencies for each rung of the ladder. -SOFT_FREQUENCY = 440 -MEDIUM_FREQUENCY = 1000 -LOUD_LOW_FREQUENCY = 800 -LOUD_HIGH_FREQUENCY = 1200 - - -@dataclass(frozen=True) -class BeepPlan: - """What the beep loop should do for one iteration. - - Attributes: - frequency: Tone to play, or None when the failsafe has silenced audio. - interval: Seconds to wait before reconsidering. - phase: Name of the rung, for logging. - silenced: True once the failsafe has stopped the sound. - """ - - frequency: int | None - interval: float - phase: str - silenced: bool - - -def plan_for(elapsed_seconds: float) -> BeepPlan: - """Choose the beep behaviour for a ring that started *elapsed_seconds* ago. - - Args: - elapsed_seconds: Seconds since the alarm began sounding. - - Returns: - The plan for this iteration. - """ - if elapsed_seconds >= FAILSAFE_SILENCE_SECONDS: - # Audio only. The caller keeps the lock and the challenge up. - return BeepPlan( - frequency=None, - interval=LOUD_TOGGLE_INTERVAL, - phase="failsafe-silent", - silenced=True, - ) - elapsed_minutes = elapsed_seconds / 60.0 - if elapsed_minutes < PHASE_SOFT_END: - return BeepPlan(SOFT_FREQUENCY, SOFT_BEEP_INTERVAL, "soft", silenced=False) - if elapsed_minutes < PHASE_MEDIUM_END: - return BeepPlan( - MEDIUM_FREQUENCY, - MEDIUM_BEEP_INTERVAL, - "medium", - silenced=False, - ) - # Alternate two tones: a changing pitch stays salient where a constant one - # is tuned out. - alternate = int(elapsed_minutes * 10) % 2 == 0 - frequency = LOUD_LOW_FREQUENCY if alternate else LOUD_HIGH_FREQUENCY - return BeepPlan(frequency, LOUD_TOGGLE_INTERVAL, "loud", silenced=False) diff --git a/wake_alarm/_escape_dialog.py b/wake_alarm/_escape_dialog.py deleted file mode 100644 index f9a133d..0000000 --- a/wake_alarm/_escape_dialog.py +++ /dev/null @@ -1,201 +0,0 @@ -"""The escape-hatch dialog — the Tk form the user fills in to bail out. - -Split out of ``_escape_flow.py`` for the 250-line cap. That module keeps the -policy (whether an escape may be offered at all, and the budget it spends); -this one is only the window. -""" - -from __future__ import annotations - -from dataclasses import dataclass -import logging -import tkinter as tk -from typing import TYPE_CHECKING - -from gatelock import ( - EscapeDraft, - bind_activate, - bind_cancel, - escape_text_tab_trap, -) - -if TYPE_CHECKING: - from gatelock import EscapeTracker, LockConfig - - from wake_alarm._escape_flow import EscapeCallbacks - -_logger = logging.getLogger(__name__) - - -@dataclass -class _FormFields: - """The four inputs the justification is read back out of.""" - - reason: tk.Entry - onset: tk.Entry - severity: tk.Entry - description: tk.Text - - -class EscapeDialog: - """The justification form and its escalating wait.""" - - def __init__( - self, - parent: tk.Misc, - config: LockConfig, - tracker: EscapeTracker, - callbacks: EscapeCallbacks, - ) -> None: - """Build the form inside ``parent``.""" - self._tracker = tracker - self._callbacks = callbacks - self._remaining = 0 - - self.frame = tk.Frame(parent, bg=config.bg) - self._status = tk.Label( - self.frame, - text=tracker.budget_summary(), - font=config.font("body"), - fg=config.muted, - bg=config.bg, - ) - self._status.pack(pady=config.space("xs")) - - tk.Label( - self.frame, - text="Why can you not do this right now?", - font=config.font("subtitle", bold=True), - fg=config.fg, - bg=config.bg, - ).pack(pady=config.space("sm")) - - tk.Label( - self.frame, - text=tracker.format_recent(), - font=config.font("caption", family="Courier"), - fg=config.muted, - bg=config.bg, - justify="left", - ).pack(pady=config.space("xs")) - - description = tk.Text( - self.frame, - height=4, - width=60, - wrap="word", - bg=config.field_bg, - fg=config.fg, - insertbackground=config.fg, - **config.focus_kwargs(), - ) - # Tk traps Tab inside a Text (inserts a literal tab and refocuses the - # widget) and binds Shift-Tab to nothing, so the only exits are the - # undocumented Ctrl+Tab / Ctrl+Shift+Tab. Unlike diet_guard's equivalent - # box, nothing here bound Return either -- so a keyboard-only user who - # tabbed into this justification field at 6am could not reach Submit, - # inside an alarm whose whole point is that it resists dismissal. - escape_text_tab_trap(description) - self._fields = _FormFields( - reason=self._field("What is wrong", config), - onset=self._field("When did it start", config), - severity=self._field("Severity 1-10", config), - description=description, - ) - description.pack(pady=config.space("xs")) - - self._complaint = tk.Label( - self.frame, - text="", - font=config.font("label"), - fg=config.danger, - bg=config.bg, - ) - self._complaint.pack(pady=config.space("xs")) - - self._submit = tk.Button( - self.frame, - text="Submit", - font=config.font("body", bold=True), - bg=config.warning, - fg=config.on_fill, - command=self.submit, - **config.focus_kwargs(), - ) - self._submit.pack(pady=config.space("xs")) - - back = tk.Button( - self.frame, - text="Back to the alarm", - font=config.font("label"), - bg=config.field_bg, - fg=config.fg, - command=callbacks.on_cancel, - **config.focus_kwargs(), - ) - back.pack(pady=config.space("xs")) - - # Tk binds only on Button on X11, never , so Enter -- - # the key a user reaches for to submit a form -- did nothing at all. - for button in (self._submit, back): - bind_activate(button) - # Escape returns to the alarm. This cancels the *escape-hatch form*, not - # the alarm: the alarm still has to be dismissed by solving a challenge - # or by spending the budgeted hatch. Making the sanctioned way back - # keyboard-reachable does not weaken that gating. - bind_cancel(self.frame, callbacks.on_cancel) - - def _field(self, label: str, config: LockConfig) -> tk.Entry: - """Add a labelled single-line field and return its entry.""" - tk.Label( - self.frame, - text=label, - font=config.font("caption"), - fg=config.muted, - bg=config.bg, - ).pack() - entry = tk.Entry(self.frame, width=40, **config.focus_kwargs()) - entry.pack(pady=config.space("xs")) - return entry - - def _draft(self) -> EscapeDraft: - """Read the form into a draft.""" - try: - severity = int(self._fields.severity.get().strip() or "0") - except ValueError: - severity = 0 - return EscapeDraft( - reason=self._fields.reason.get(), - onset=self._fields.onset.get(), - severity=severity, - description=self._fields.description.get("1.0", "end-1c"), - ) - - def submit(self) -> None: - """Validate, then start the escalating wait.""" - draft = self._draft() - complaint = self._tracker.validate(draft) - if complaint is not None: - self._complaint.configure(text=complaint) - return - if self._tracker.is_budget_exhausted(): - # Re-checked at submit time: the budget could have been spent by - # another surface, or the day could have rolled over mid-form. - self._complaint.configure(text="No budget left. Solve the challenge.") - return - self._tracker.record(draft) - self._remaining = self._tracker.compute_lockout_seconds() - self._submit.configure(state="disabled") - self._tick() - - def _tick(self) -> None: - """Count the wait down, then grant.""" - if self._remaining <= 0: - _logger.warning( - "alarm escape hatch used -- %s", self._tracker.budget_summary() - ) - self._callbacks.on_granted() - return - self._complaint.configure(text=f"Wait {self._remaining}s...") - self._remaining -= 1 - self.frame.after(1000, self._tick) diff --git a/wake_alarm/_escape_flow.py b/wake_alarm/_escape_flow.py deleted file mode 100644 index 1b106b1..0000000 --- a/wake_alarm/_escape_flow.py +++ /dev/null @@ -1,143 +0,0 @@ -"""The alarm's sanctioned way out when the challenge genuinely isn't happening. - -Until now the alarm had exactly one exit: four correct challenge rounds against -a 30-second refresh. That is a lot to ask of someone who has just woken up, and -it is *nothing* to fall back on if the alarm misfires at the wrong hour. Every -other locker here has a bounded escape -- screen-locker's sick mode, -diet-guard's log-a-meal -- so the alarm now has the same, built on the shared -:mod:`gatelock._escape` core rather than a private copy. - -The shape, deliberately identical to sick mode: - -* the button only appears after the alarm has been up a while, so it is never - the *easy* first move; -* it disappears entirely once any rolling budget is spent; -* it costs a written justification, with the last ten shown back to you; -* it costs a wait that doubles with each recent use; -* every entry is HMAC-signed, so editing the history is detectable. -""" - -from __future__ import annotations - -import contextlib -from dataclasses import dataclass -import logging -import tkinter as tk -from typing import TYPE_CHECKING - -from gatelock import ( - EscapePolicy, - EscapeTracker, -) - -from wake_alarm._constants import ESCAPE_HISTORY_FILE, ESCAPE_OFFER_AFTER_SECONDS -from wake_alarm._escape_dialog import EscapeDialog, _FormFields - -# Imported under their original names: the suite patches -# `wake_alarm._escape_flow.EscapeDialog`, and a moved symbol makes patch() -# stop intercepting silently. __all__ stops ruff deleting them as unused. -__all__ = ["EscapeDialog", "EscapeHatch", "_FormFields"] - -if TYPE_CHECKING: - from collections.abc import Callable - - from wake_alarm._alarm_view import _ViewGroup - - -_logger = logging.getLogger(__name__) - -ALARM_ESCAPE_POLICY = EscapePolicy( - name="cant-wake", - label="Can't-wake", -) -"""Budgets left at the shared defaults, so this is exactly as hard as sick mode.""" - - -def build_tracker() -> EscapeTracker: - """Build the alarm's escape tracker over its own history file.""" - tracker = EscapeTracker(ALARM_ESCAPE_POLICY, ESCAPE_HISTORY_FILE) - tracker.load() - return tracker - - -def is_offerable(tracker: EscapeTracker, elapsed_seconds: float) -> bool: - """Whether the hatch should be shown at all. - - Two independent gates: the alarm must have been up long enough that this - is not the first thing you reach for, and the budget must not be spent. - """ - if elapsed_seconds < ESCAPE_OFFER_AFTER_SECONDS: - return False - return not tracker.is_budget_exhausted() - - -@dataclass(frozen=True) -class EscapeCallbacks: - """Who to tell how the form ended.""" - - on_granted: Callable[[], None] - on_cancel: Callable[[], None] - - -class EscapeHatch: - """The alarm's way out, from the hidden button to putting the screen back. - - The alarm delegates the whole flow here so that opening and closing the - form stay symmetric: every ``open`` that hides a challenge and builds a - dialog has a ``close`` that destroys the dialog and packs the challenge - back. Splitting those halves across two objects is what previously left - "Back to the alarm" showing a blank screen. - """ - - def __init__( - self, - tracker: EscapeTracker, - views: _ViewGroup, - on_granted: Callable[[], None], - ) -> None: - """Bind the hatch to the surfaces it swaps and the alarm it releases.""" - self._tracker = tracker - self._views = views - self._on_granted = on_granted - self._dialogs: list[EscapeDialog] = [] - self.is_open = False - - def maybe_offer(self, elapsed_seconds: float) -> None: - """Reveal the button on every monitor once it is earned, and only then.""" - if self.is_open: - return - if not is_offerable(self._tracker, elapsed_seconds): - return - for button in self._views.escape_buttons: - with contextlib.suppress(tk.TclError): - button.pack(pady=self._views.config.space("sm")) - - def open(self) -> None: - """Swap every surface over to the justification form.""" - self.is_open = True - callbacks = EscapeCallbacks( - on_granted=self._on_granted, - on_cancel=self.close, - ) - for view in self._views.surfaces: - with contextlib.suppress(tk.TclError): - view.challenge_frame.pack_forget() - dialog = EscapeDialog( - view.container, - self._views.config, - self._tracker, - callbacks, - ) - dialog.frame.pack() - self._dialogs.append(dialog) - - def close(self) -> None: - """Back out of the form and put the challenge back on every monitor.""" - for dialog in self._dialogs: - with contextlib.suppress(tk.TclError): - dialog.frame.destroy() - self._dialogs.clear() - for view in self._views.surfaces: - with contextlib.suppress(tk.TclError): - view.challenge_frame.pack() - self.is_open = False diff --git a/wake_alarm/_gating.py b/wake_alarm/_gating.py deleted file mode 100644 index 20dd5b3..0000000 --- a/wake_alarm/_gating.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Decide whether the alarm should run, and parse its CLI. - -Split out of ``_alarm.py`` so the gate is testable without constructing a -window, and to keep that module under the 250-line cap. - -Note ``_should_run_alarm`` has deliberately **no time gate**: the trigger owns -"is it time?", and this owns "should it run at all today?". That separation is -why ``wake-alarm.service`` must never get an ``[Install]`` section -- with no -time gate, a ``WantedBy=`` would fire the alarm at login. -""" - -from __future__ import annotations - -import argparse -from datetime import UTC, datetime -import logging - -from wake_alarm._constants import ALARM_DAYS -from wake_alarm._state import was_alarm_dismissed_today, was_workout_logged_today - -_logger = logging.getLogger(__name__) - - -def is_alarm_day(*, now: datetime | None = None) -> bool: - """Report whether today is configured as an alarm day. - - Args: - now: Instant to test; defaults to the current local time. - - Returns: - True when today's weekday is in ``ALARM_DAYS``. - """ - moment = now if now is not None else datetime.now(tz=UTC).astimezone() - return moment.weekday() in ALARM_DAYS - - -def should_run_alarm() -> bool: - """Decide whether the alarm should sound at all today. - - Returns: - True unless today is not an alarm day, the alarm was already - dismissed, or the workout was already logged. - """ - if not is_alarm_day(): - _logger.info("Not an alarm day. Exiting.") - return False - if was_alarm_dismissed_today(): - _logger.info("Alarm already dismissed today. Exiting.") - return False - if was_workout_logged_today(): - _logger.info("Workout already logged today. Skipping alarm.") - return False - return True - - -def parse_args(argv: list[str]) -> argparse.Namespace: - """Parse CLI arguments for the alarm daemon. - - Args: - argv: Argument list without the program name. - - Returns: - The parsed namespace. - """ - parser = argparse.ArgumentParser(description="Wake alarm daemon.") - parser.add_argument( - "--production", - action="store_true", - help="Production mode (default; kept for systemd compatibility).", - ) - parser.add_argument( - "--demo", - action="store_true", - help="Run with a smaller window and shorter timers.", - ) - parser.add_argument( - "--trigger-now", - action="store_true", - help="Bypass the day/dismiss gate and fire the alarm immediately.", - ) - return parser.parse_args(argv) diff --git a/wake_alarm/_inhibit.py b/wake_alarm/_inhibit.py deleted file mode 100644 index 3acbdda..0000000 --- a/wake_alarm/_inhibit.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Hold a logind inhibitor so one power-button press cannot kill the alarm. - -Item 2 of the hardening plan. Today a short press on the power button runs -``HandlePowerKey=poweroff`` and the alarm is gone in one tap -- less effort -than typing a single challenge round. - -Verified on this host before writing any of this: - - $ grep PowerKey /etc/systemd/logind.conf - #HandlePowerKey=poweroff - #HandlePowerKeyLongPress=ignore - #PowerKeyIgnoreInhibited=no - -``PowerKeyIgnoreInhibited=no`` is the default and is in force, which is exactly -what makes a ``--mode=block`` inhibitor suppress the short press. So no -``logind.conf`` edit is needed, and none is made: a drop-in would change the -machine permanently, while an inhibitor is scoped to the alarm's lifetime. - -**What this cannot block, stated plainly:** a ~4 second firmware hold cuts -power below the operating system, and so does the PSU switch. Neither is -defeatable from userspace. The durable defence against those is the fire-time -session record in :mod:`wake_alarm._alarm_session` -- the alarm re-fires on the -next boot rather than being escaped -- not this inhibitor. - -**Why a bounded ``sleep`` and not ``sleep infinity``:** the inhibitor is a -child process. If the alarm dies without running ``on_close()`` -- a crash, a -SIGKILL -- an orphaned blocker holding ``handle-power-key`` would leave the -power button dead until the next reboot, which is a far worse failure than the -one being fixed. The child therefore exits on its own after -``INHIBIT_MAX_SECONDS`` even if nobody ever releases it, and a stale one from a -previous run is killed at startup -- by pid, via ``/proc``, never by matching a -command-line substring (see :func:`_is_our_inhibitor` for why). -""" - -from __future__ import annotations - -import logging -import os -import pathlib -import shutil -import signal -import subprocess - -from wake_alarm._constants import ( - INHIBIT_CONFIRM_SECONDS, - INHIBIT_MAX_SECONDS, - INHIBIT_WHAT, - INHIBIT_WHO, - INHIBIT_WHY, -) - -_logger = logging.getLogger(__name__) - - -def _systemd_inhibit_path() -> str | None: - """Return the absolute path to systemd-inhibit, or None when absent.""" - return shutil.which("systemd-inhibit") - - -def _is_our_inhibitor(pid: str) -> bool: - """Report whether *pid* is one of this alarm's inhibitor children. - - Reads the process's own argv rather than trusting a pattern match. This - matters: an earlier version used ``pkill -f "--who=wake-alarm"``, which on - a real run matched the *shell that had just typed that pattern* and killed - it instead of the inhibitor. Matching a command line by substring is - inherently self-matching; asking a specific pid what it is, is not. - """ - try: - argv = pathlib.Path("/proc", pid, "cmdline").read_bytes() - except OSError: - # The process exited between listing and reading -- it is not stale, - # it is simply gone. - return False - return f"--who={INHIBIT_WHO}".encode() in argv - - -def kill_stale_inhibitors() -> int: - """Kill any inhibitor left behind by an earlier alarm process. - - A blocker that outlived its alarm holds the power button down for every - later session, so this runs before a new one is taken rather than trusting - that the previous process exited cleanly. - - Matches on the exact process name and then confirms via ``/proc``, so it - can only ever kill a real ``systemd-inhibit`` of ours. - - Returns: - The number of stale processes signalled. - """ - pgrep = shutil.which("pgrep") - if pgrep is None: - return 0 - try: - listed = subprocess.run( - [pgrep, "-x", "systemd-inhibit"], - capture_output=True, - timeout=5, - check=False, - ) - except OSError, subprocess.TimeoutExpired: - _logger.warning("Could not check for a stale power-key inhibitor") - return 0 - killed = 0 - for pid in listed.stdout.decode(errors="replace").split(): - if not _is_our_inhibitor(pid): - continue - try: - os.kill(int(pid), signal.SIGTERM) - except OSError, ValueError: - continue - killed += 1 - if killed: - _logger.warning( - "Killed %d stale power-key inhibitor(s) left by an earlier alarm", - killed, - extra={"event": "inhibitor_stale_killed", "count": killed}, - ) - return killed - - -def take_power_key_inhibitor() -> subprocess.Popen[bytes] | None: - """Block the power key for the alarm's lifetime. - - Returns: - The inhibitor child process, or None when systemd-inhibit is missing - or could not be started. A missing inhibitor never stops the alarm: - being loud without the power key blocked beats not ringing at all. - """ - inhibit = _systemd_inhibit_path() - if inhibit is None: - _logger.warning( - "systemd-inhibit not on PATH; the power button can still kill " - "the alarm in one press", - extra={"event": "inhibitor_unavailable"}, - ) - return None - kill_stale_inhibitors() - cmd = [ - inhibit, - f"--what={INHIBIT_WHAT}", - "--mode=block", - f"--who={INHIBIT_WHO}", - f"--why={INHIBIT_WHY}", - "sleep", - str(int(INHIBIT_MAX_SECONDS)), - ] - try: - # Deliberately not a context manager: this child must outlive the - # call and is reaped by release_power_key_inhibitor(). - process = subprocess.Popen( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - except OSError: - _logger.warning( - "Could not take the power-key inhibitor; the alarm rings unprotected", - exc_info=True, - extra={"event": "inhibitor_failed"}, - ) - return None - - # Popen succeeding only means the *binary* started. logind can still - # refuse the inhibit -- polkit denies it outright for a non-interactive - # session, which is exactly what a vmbox run turned up: systemd-inhibit - # printed "Failed to inhibit: Access denied" and exited 1 while this - # function happily reported the power key blocked. Claiming protection - # that is not there is worse than reporting none, so wait briefly and - # check the child is actually alive. - try: - process.wait(timeout=INHIBIT_CONFIRM_SECONDS) - except subprocess.TimeoutExpired: - pass # Still running after the grace period: the inhibit was accepted. - else: - stderr = b"" if process.stderr is None else process.stderr.read() - _logger.warning( - "logind refused the power-key inhibitor (rc=%d): %s — the alarm " - "rings unprotected", - process.returncode, - stderr.decode(errors="replace").strip() or "no detail", - extra={"event": "inhibitor_refused", "rc": process.returncode}, - ) - return None - - _logger.warning( - "Power key inhibited for up to %ds (pid %d) — a short press no longer " - "kills the alarm; a ~4s firmware hold still cuts power", - int(INHIBIT_MAX_SECONDS), - process.pid, - extra={"event": "inhibitor_taken", "pid": process.pid}, - ) - return process - - -def release_power_key_inhibitor(process: subprocess.Popen[bytes] | None) -> None: - """Release the inhibitor taken by :func:`take_power_key_inhibitor`. - - Safe to call more than once and safe to call with None, because it runs - from ``on_close()``, which itself runs on every exit path. - - Args: - process: The inhibitor child, or None when one was never taken. - """ - if process is None: - return - if process.poll() is not None: - return - process.terminate() - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - _logger.warning("Power-key inhibitor ignored SIGTERM; killed it") - _logger.info("Power key released", extra={"event": "inhibitor_released"}) diff --git a/wake_alarm/_lock_setup.py b/wake_alarm/_lock_setup.py deleted file mode 100644 index ccc51c6..0000000 --- a/wake_alarm/_lock_setup.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Build the gatelock configuration and claim the screen-lock arbiter. - -Split out of ``_alarm.py`` for the 250-line cap. - -Production hard-locks; demo stays grab-free so a manual test cannot trap the -developer. Demo still takes ``overrideredirect``, because per-output placement -is impossible without it -- a window manager rewrites a managed window's -geometry. -""" - -from __future__ import annotations - -from gatelock import RANK_WAKE_ALARM, Arbiter, LockConfig - -APP_NAME = "wake_alarm" - - -def build_config(*, demo_mode: bool) -> LockConfig: - """Build the lock configuration for this run. - - Args: - demo_mode: True for the closeable manual-test window. - - Returns: - The configuration to hand to :class:`LockWindow`. - """ - return LockConfig( - mode="soft" if demo_mode else "hard", - overrideredirect=True, - app_name=APP_NAME, - rank=RANK_WAKE_ALARM, - ) - - -def claim_arbiter(config: LockConfig) -> Arbiter: - """Publish and acquire the arbiter for *config*. - - The caller must hand the result to :class:`LockWindow`, which takes - ownership and releases it on close. - - Args: - config: The lock configuration being used. - - Returns: - The acquired arbiter. - """ - arbiter = Arbiter( - APP_NAME, - RANK_WAKE_ALARM, - grab=config.resolved_grab(), - disable_vt=config.resolved_disable_vt(), - ) - arbiter.publish() - arbiter.acquire_holder() - return arbiter diff --git a/wake_alarm/_reactivate.py b/wake_alarm/_reactivate.py deleted file mode 100644 index acddaf7..0000000 --- a/wake_alarm/_reactivate.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Keep retrying card-profile activation while the alarm rings. - -The bug this exists to fix, measured on the host: - - $ pactl list cards | grep -A1 'alsa_card.pci-0000_01_00.1' - Active Profile: off - $ pactl list short sinks - 61 alsa_output.pci-0000_0d_00.6.analog-stereo IDLE - -The G27Q's HDMI sink is simply **absent** while its card profile is ``off``, -so re-enumerating sinks every beep cycle can never find it -- there is nothing -to find. Only re-running ``set-card-profile`` brings the sink into existence. - -At wake time the monitor has not finished its HDMI handshake, so the profile -reads ``available: no``, ``_parse_profile_line`` drops it, and the card is -never revisited. One activation pass at startup is therefore not enough: the -retry has to keep going for as long as an output might still be arriving. - -Bounded and non-blocking: a background thread with a decaying schedule, so the -beep loop is never delayed by a card that will never appear. -""" - -from __future__ import annotations - -from collections.abc import Callable, Iterable -import logging -import threading -import time - -from wake_alarm._constants import REACTIVATE_ATTEMPT_DELAYS -from wake_alarm._sinks import _activate_all_outputs, _list_real_sinks, _pactl_path - -_Activate = Callable[[], object] -_ListSinks = Callable[[], Iterable[str]] -_Sleep = Callable[[float], object] - -_logger = logging.getLogger(__name__) - - -def _attempt( - activate: _Activate, list_sinks: _ListSinks, attempt: int -) -> frozenset[str]: - """Run one activation pass and report the sinks that resulted. - - Args: - activate: Callable re-running card-profile activation. - list_sinks: Callable returning the current real sink names. - attempt: 1-based attempt number, for the log line. - - Returns: - The set of sink names visible after this pass. - """ - activate() - sinks = frozenset(list_sinks()) - _logger.info( - "Sink reactivation pass %d: %d sink(s) present: %s", - attempt, - len(sinks), - ", ".join(sorted(sinks)) or "(none)", - extra={"sinks": sorted(sinks), "attempt": attempt}, - ) - return sinks - - -def reactivate_until_stable( - activate: _Activate, - list_sinks: _ListSinks, - *, - sleep: _Sleep, - stop: threading.Event | None = None, -) -> frozenset[str]: - """Re-run activation on a decaying schedule, logging what appears. - - Every pass is logged with the full enumerated sink list, so a future - silent alarm can be diagnosed from the JSONL log instead of guessed at. - - Args: - activate: Callable re-running card-profile activation. - list_sinks: Callable returning the current real sink names. - sleep: Callable delaying between passes (injected for tests). - stop: Set once the alarm is dismissed, to end retrying early. - - Returns: - The sink set observed on the final pass. - """ - seen = _attempt(activate, list_sinks, 1) - for index, delay in enumerate(REACTIVATE_ATTEMPT_DELAYS, start=2): - if stop is not None and stop.is_set(): - break - sleep(delay) - if stop is not None and stop.is_set(): - break - current = _attempt(activate, list_sinks, index) - gained = current - seen - if gained: - _logger.warning( - "New audio output(s) appeared %s into the alarm: %s", - "late", - ", ".join(sorted(gained)), - extra={"gained": sorted(gained)}, - ) - seen = current - return seen - - -def start_background_reactivation( - activate: _Activate, - list_sinks: _ListSinks, - *, - sleep: _Sleep, - stop: threading.Event, -) -> threading.Thread: - """Run :func:`reactivate_until_stable` on a daemon thread. - - Args: - activate: Callable re-running card-profile activation. - list_sinks: Callable returning the current real sink names. - sleep: Callable delaying between passes. - stop: Set once the alarm is dismissed. - - Returns: - The started thread. - """ - thread = threading.Thread( - target=reactivate_until_stable, - args=(activate, list_sinks), - kwargs={"sleep": sleep, "stop": stop}, - daemon=True, - name="wake-alarm-reactivate", - ) - thread.start() - return thread - - -def _activate_outputs_only() -> None: - """Re-run card-profile activation, discarding the captured prior state. - - The return value of :func:`_activate_all_outputs` is the audio state as it - was *before* that call, and only the very first call sees the user's real - settings -- every retry pass captures the alarm's own already-activated - state. Restoring one of those would restore the alarm's settings as if they - were the user's, so the retries deliberately return nothing at all rather - than handing back a value someone could later wire into ``on_close()``. - """ - _activate_all_outputs() - - -def _list_real_sinks_now() -> list[str]: - """Return the currently visible real sinks, or none when pactl is absent. - - :func:`wake_alarm._sinks._list_real_sinks` needs the pactl path, which is - resolved fresh each pass: the retry outlives the startup lookup, and a - machine without pactl must report "no sinks" rather than raise on a - background thread nobody is watching. - """ - pactl = _pactl_path() - if pactl is None: - return [] - return _list_real_sinks(pactl) - - -def start_alarm_reactivation(stop: threading.Event) -> threading.Thread: - """Start the real reactivation retry for a ringing alarm. - - The wiring point: everything above takes injected callables so it can be - tested without an audio stack, and this is the one place that binds them to - the real ``pactl``. - - Args: - stop: The alarm's beep-stop event, set once it is dismissed. - - Returns: - The started daemon thread. - """ - return start_background_reactivation( - _activate_outputs_only, - _list_real_sinks_now, - sleep=time.sleep, - stop=stop, - ) diff --git a/wake_alarm/_rounds.py b/wake_alarm/_rounds.py deleted file mode 100644 index 7a7ad09..0000000 --- a/wake_alarm/_rounds.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Present a dismiss challenge and judge the answer. - -Split out of ``_alarm.py`` for the 250-line cap. Everything here manipulates -the view for one round; the alarm keeps ownership of *how many* rounds are -needed and what happens when they are all done. -""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING - -from wake_alarm._countdown import ( - NO_SKIP_STATUS, - WINDOW_CLOSED_INFO, - countdown_text, - refresh_interval_ms, - remaining_seconds, - skip_window_ms, - skip_window_seconds, -) -from wake_alarm._dismiss_view import BANNER_LINGER_MS, show_dismissal -from wake_alarm._screen_flash import FLASH_PERIOD_MS, apply_flash_colour - -__all__ = [ - "BANNER_LINGER_MS", - "FLASH_PERIOD_MS", - "NO_SKIP_STATUS", - "WINDOW_CLOSED_INFO", - "apply_flash_colour", - "countdown_text", - "hide_flash_code", - "normalise", - "refresh_interval_ms", - "remaining_seconds", - "show_challenge", - "show_dismissal", - "show_flash_countdown", - "show_round_progress", - "show_wrong_answer", - "skip_window_ms", - "skip_window_seconds", -] - -if TYPE_CHECKING: - from wake_alarm._alarm_view import _ViewGroup - from wake_alarm._challenges import _Challenge - - -def normalise(entered: str) -> str: - """Canonicalise typed input before comparison. - - Args: - entered: Raw contents of the entry widget. - - Returns: - Trimmed, upper-cased text. - """ - return entered.strip().upper() - - -def show_challenge(view: _ViewGroup, challenge: _Challenge) -> None: - """Display *challenge* and clear the entry ready for the answer. - - Args: - view: The multi-monitor view group. - challenge: The challenge to present. - """ - view.code_label.configure(text=challenge.display, fg=view.config.success) - view.info_label.configure(text=challenge.hint) - view.entry.delete(0, tk.END) - - -def show_wrong_answer(view: _ViewGroup, challenge: _Challenge) -> None: - """Report an incorrect answer and re-present the same challenge. - - Args: - view: The multi-monitor view group. - challenge: The challenge that was answered incorrectly. - """ - view.status_label.configure(text="Wrong! Try again.") - view.entry.delete(0, tk.END) - if challenge.kind == "flash": - view.code_label.configure(text=challenge.display, fg=view.config.success) - - -def show_flash_countdown(view: _ViewGroup, remaining: int) -> None: - """Show how long the flash challenge stays visible. - - Args: - view: The multi-monitor view group. - remaining: Seconds before the code is hidden. - """ - view.status_label.configure(text=f"Memorise! Hiding in {remaining}s…") - - -def hide_flash_code(view: _ViewGroup, challenge: _Challenge) -> None: - """Mask the flash code and prompt for it from memory. - - Args: - view: The multi-monitor view group. - challenge: The challenge whose display is being hidden. - """ - view.code_label.configure( - text="?" * len(challenge.display), - fg=view.config.muted, - ) - view.status_label.configure(text="Now type the code from memory!") - - -def show_round_progress(view: _ViewGroup, *, completed: int, required: int) -> None: - """Update the round counter and the encouragement line. - - Args: - view: The multi-monitor view group. - completed: How many rounds are done. - required: How many rounds are needed in total. - """ - view.round_label.configure(text=f"Round {completed + 1} / {required}") - view.status_label.configure(text=f"Round {completed} done — keep going!") diff --git a/wake_alarm/_screen_flash.py b/wake_alarm/_screen_flash.py deleted file mode 100644 index 5d0b239..0000000 --- a/wake_alarm/_screen_flash.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Alternate the lock screen's background colour to hold attention. - -Split out of ``_alarm.py`` for the 250-line cap. The 750 ms period is -deliberate: it is ~1.3 Hz, comfortably under the ~3 Hz threshold associated -with photosensitive seizures. Do not shorten it to make the alarm "more -urgent" -- the escalating audio is the right lever for that. -""" - -from __future__ import annotations - -import contextlib -import tkinter as tk -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Iterable - -# Milliseconds between colour swaps. ~1.3 Hz; see the module docstring. -FLASH_PERIOD_MS = 750 - - -def apply_flash_colour( - root: tk.Tk, - containers: Iterable[tk.Frame], - colour: str, -) -> None: - """Paint *colour* onto the backdrop and every per-output surface. - - The surfaces sit on top of the root backdrop, so recolouring only the root - would be invisible on every monitor. - - Args: - root: The root window holding the backdrop. - containers: Per-output container widgets. - colour: The background colour to apply. - """ - root.configure(bg=colour) - for container in containers: - # A surface torn down mid-flash must not stop the others repainting. - with contextlib.suppress(tk.TclError): - container.configure(bg=colour) - container.winfo_toplevel().configure(bg=colour) diff --git a/wake_alarm/_sink_parsing.py b/wake_alarm/_sink_parsing.py deleted file mode 100644 index e12c1a3..0000000 --- a/wake_alarm/_sink_parsing.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Parsing the text ``pactl`` prints — cards, profiles and sinks. - -Split out of ``_sinks.py`` for the 250-line cap. This half is pure text -handling with no subprocess and no side effects, so it is testable without -touching the host's audio at all; ``_sinks.py`` keeps the command execution -and the activate/restore orchestration. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -import re - - -def _parse_profile_line(line: str) -> tuple[str, int] | None: - """Parse one ``output:*`` profile line into ``(name, priority)``. - - Returns None for non-output profiles and for profiles marked - ``available: no`` (an output whose sink cannot currently be produced, - e.g. an HDMI port with nothing plugged in). - """ - if not line.startswith("output:"): - return None - name, sep, rest = line.partition(": ") - if not sep or "available: no" in rest: - return None - match = re.search(r"priority:\s*(\d+)", rest) - priority = int(match.group(1)) if match else 0 - return name, priority - - -@dataclass -class _CardInfo: - """A card's active profile and its available ``output:*`` profiles.""" - - active_profile: str | None - # Available output profiles, best first (stereo preferred, then priority). - output_profiles: list[str] - - -@dataclass -class _CardAccum: - """Mutable accumulator for one card block while parsing ``list cards``.""" - - name: str | None = None - active: str | None = None - profiles: list[tuple[str, int]] = field(default_factory=list) - - -def _parse_cards(text: str) -> dict[str, _CardInfo]: - """Parse ``pactl list cards`` (full) into ``{card_name: _CardInfo}``.""" - cards: dict[str, _CardInfo] = {} - cur: _CardAccum | None = None - in_profiles = False - for raw in text.splitlines(): - if raw.startswith("Card #"): - _store_card(cards, cur) - cur = _CardAccum() - in_profiles = False - continue - if cur is None: - continue - stripped = raw.strip() - if stripped.startswith("Name:"): - cur.name = stripped.split(":", 1)[1].strip() - in_profiles = False - elif stripped.startswith("Active Profile:"): - cur.active = stripped.split(":", 1)[1].strip() - in_profiles = False - elif stripped == "Profiles:": - in_profiles = True - elif in_profiles: - parsed = _parse_profile_line(stripped) - if parsed is not None: - cur.profiles.append(parsed) - _store_card(cards, cur) - return cards - - -def _store_card(cards: dict[str, _CardInfo], cur: _CardAccum | None) -> None: - """Finalise one parsed card block and add it to *cards* (skip if unnamed).""" - if cur is None or cur.name is None: - return - # Prefer a stereo profile, then the highest priority. - ordered = sorted( - cur.profiles, key=lambda np: (0 if "stereo" in np[0] else 1, -np[1]) - ) - cards[cur.name] = _CardInfo( - active_profile=cur.active, - output_profiles=[profile for profile, _ in ordered], - ) - - -def _parse_sinks(text: str) -> tuple[dict[str, str], dict[str, bool]]: - """Parse ``pactl list sinks`` into ``({sink: raw_volume}, {sink: muted})``.""" - volumes: dict[str, str] = {} - mutes: dict[str, bool] = {} - name: str | None = None - for raw in text.splitlines(): - if raw.startswith("Sink #"): - name = None - continue - stripped = raw.strip() - if stripped.startswith("Name:"): - name = stripped.split(":", 1)[1].strip() - elif name is None: - continue - elif stripped.startswith("Mute:"): - mutes[name] = stripped.split(":", 1)[1].strip() == "yes" - elif stripped.startswith("Volume:"): - match = re.search(r"(\d+)", stripped) - if match: - volumes[name] = match.group(1) - return volumes, mutes diff --git a/wake_alarm/_sinks.py b/wake_alarm/_sinks.py deleted file mode 100644 index 91005de..0000000 --- a/wake_alarm/_sinks.py +++ /dev/null @@ -1,249 +0,0 @@ -"""PipeWire sink/card enumeration, activation, and restore for the wake alarm. - -At wake time the Bluetooth speaker is gone and WirePlumber leaves most card -profiles ``off``, so the machine is silent. This module forces every card's -output on, unmutes and maxes every sink, and captures the prior state so the -alarm can restore it on dismiss. The alarm plays on *all* sinks every beep -cycle (see :func:`wake_alarm._audio._play_on_all_sinks`) rather than betting on -a single chosen output. -""" - -from __future__ import annotations - -from dataclasses import dataclass -import logging -import shutil -import subprocess - -from wake_alarm._constants import ( - ALARM_AUDIO_SINK, -) -from wake_alarm._sink_parsing import ( - _CardAccum, - _CardInfo, - _parse_cards, - _parse_sinks, -) - -# Imported under their original names because the suite patches -# `wake_alarm._sinks._parse_cards` and friends: a moved symbol makes patch() -# stop intercepting *silently*. __all__ stops ruff deleting them as unused. -__all__ = ["_CardAccum", "_CardInfo", "_parse_cards", "_parse_sinks"] - -_logger = logging.getLogger(__name__) - - -def _pactl_path() -> str | None: - """Return the absolute path to pactl, or None when not installed.""" - return shutil.which("pactl") - - -def _pactl_text(pactl: str, *args: str, timeout: float = 5.0) -> str | None: - """Run ``pactl `` and return decoded stdout, or None on failure.""" - try: - result = subprocess.run( - [pactl, *args], - capture_output=True, - timeout=timeout, - check=False, - ) - except OSError, subprocess.TimeoutExpired: - _logger.warning("pactl %s failed", " ".join(args), exc_info=True) - return None - return result.stdout.decode(errors="replace") - - -def _list_real_sinks(pactl: str) -> list[str]: - """Return every non-``auto_null`` sink name from ``pactl list short sinks``.""" - text = _pactl_text(pactl, "list", "short", "sinks") - if text is None: - return [] - names = [line.split("\t")[1] for line in text.splitlines() if "\t" in line] - return [name for name in names if name != "auto_null"] - - -def _list_cards(pactl: str) -> list[str]: - """Return every card name from ``pactl list short cards``.""" - text = _pactl_text(pactl, "list", "short", "cards") - if text is None: - return [] - return [line.split("\t")[1] for line in text.splitlines() if "\t" in line] - - -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, - ) - except OSError, subprocess.TimeoutExpired: - _logger.warning("pactl get-default-sink failed", exc_info=True) - return None - name = result.stdout.decode(errors="replace").strip() - return name or None - - -def _ensure_sink_audible(pactl: str, sink: str) -> None: - """Unmute *sink* and raise it to 100%, so playback on it is actually heard.""" - for args in ( - ["set-sink-mute", sink, "0"], - ["set-sink-volume", sink, "100%"], - ): - subprocess.run( - [pactl, *args], - capture_output=True, - timeout=3, - check=False, - ) - - -@dataclass -class _AudioState: - """Snapshot of audio state to restore after the alarm is dismissed.""" - - default_sink: str | None - card_profiles: dict[str, str] # card name -> active profile - sink_volumes: dict[str, str] # sink name -> raw first-channel volume - sink_mutes: dict[str, bool] # sink name -> muted? - - -def _capture_audio_state(pactl: str) -> _AudioState: - """Capture every card profile, sink volume/mute, and the default sink.""" - cards = _parse_cards(_pactl_text(pactl, "list", "cards") or "") - card_profiles = { - name: info.active_profile - for name, info in cards.items() - if info.active_profile is not None - } - sink_volumes, sink_mutes = _parse_sinks(_pactl_text(pactl, "list", "sinks") or "") - return _AudioState( - default_sink=_current_default_sink(pactl), - card_profiles=card_profiles, - sink_volumes=sink_volumes, - sink_mutes=sink_mutes, - ) - - -def _activate_all_outputs() -> _AudioState | None: - """Turn on every card's output and make every sink audible for the alarm. - - At wake time the Bluetooth speaker is gone and WirePlumber leaves most card - profiles ``off``, so the alarm is silent. This forces every card's best - output profile on, then unmutes and raises every sink to 100%. The alarm - then plays on *all* sinks every beep cycle - (:func:`wake_alarm._audio._play_on_all_sinks`) — it does not pick one output - and hope: a switched-off external speaker cannot be revived by software, so - every *other* output is tried in parallel instead. - - It does not block waiting for the known-slow ``ALARM_AUDIO_SINK`` (the G27Q - monitor's HDMI speaker, ~15 s to enumerate); it activates whatever is present - now and lets the per-cycle presence check in ``_play_on_all_sinks`` sweep in - the rest. The default sink, when set, is only a convenience — per-cycle - all-sink playback is what guarantees audio. - - Returns: - The prior audio state (to restore on close), or ``None`` only when - pactl is unavailable (nothing was changed, so nothing to restore). - """ - pactl = _pactl_path() - if pactl is None: - _logger.warning("pactl not on PATH; cannot activate alarm audio") - return None - state = _capture_audio_state(pactl) - for card, info in _parse_cards(_pactl_text(pactl, "list", "cards") or "").items(): - if info.output_profiles: - subprocess.run( - [pactl, "set-card-profile", card, info.output_profiles[0]], - capture_output=True, - timeout=3, - check=False, - ) - # No sleep-and-hope wait. Forcing the profiles on above makes sinks appear - # asynchronously (the monitor's HDMI sink can take ~15 s), but we don't block - # on a timer guessing when it's "ready". We activate whatever is present - # right now; the beep loop's _play_on_all_sinks re-checks _list_real_sinks - # every cycle, so each output is made audible and played on the moment it - # actually exists — a real presence check, never a timed guess. - sinks = _list_real_sinks(pactl) - if not sinks: - _logger.warning( - "No real sink present yet; the beep loop will pick sinks up as they " - "enumerate (PC speaker covers the gap).", - ) - return state - for sink in sinks: - _ensure_sink_audible(pactl, sink) - default = ALARM_AUDIO_SINK if ALARM_AUDIO_SINK in sinks else sinks[0] - subprocess.run( - [pactl, "set-default-sink", default], - capture_output=True, - timeout=3, - check=False, - ) - _logger.warning( - "Alarm audio activated on %d present sink(s) at 100%%, default=%s; more " - "are added by the beep loop as they enumerate.", - len(sinks), - default, - ) - return state - - -def _restore_audio_state(state: _AudioState | None) -> None: - """Restore card profiles, sink volumes/mutes, and default captured earlier.""" - if state is None: - return - pactl = _pactl_path() - if pactl is None: - return - for card, profile in state.card_profiles.items(): - subprocess.run( - [pactl, "set-card-profile", card, profile], - capture_output=True, - timeout=3, - check=False, - ) - for sink, volume in state.sink_volumes.items(): - subprocess.run( - [pactl, "set-sink-volume", sink, volume], - capture_output=True, - timeout=3, - check=False, - ) - for sink, muted in state.sink_mutes.items(): - subprocess.run( - [pactl, "set-sink-mute", sink, "1" if muted else "0"], - capture_output=True, - timeout=3, - check=False, - ) - if state.default_sink is not None: - subprocess.run( - [pactl, "set-default-sink", state.default_sink], - capture_output=True, - timeout=3, - check=False, - ) - - -def _warn_if_no_real_sink() -> None: - """Log a warning when PipeWire has no real (non-auto_null) sink yet. - - Informational only: :func:`_activate_all_outputs` runs afterwards and forces - card profiles on, so a missing sink here is not necessarily fatal. - """ - pactl = _pactl_path() - if pactl is None: - _logger.warning("pactl not on PATH; cannot verify audio sinks") - return - real_sinks = _list_real_sinks(pactl) - if not real_sinks: - _logger.warning( - "No real PipeWire sink yet — alarm will force card profiles on and " - "fall back to the PC speaker.", - ) - else: - _logger.info("Audio sinks available: %s", real_sinks) diff --git a/wake_alarm/_smart_plug.py b/wake_alarm/_smart_plug.py deleted file mode 100644 index 7813e58..0000000 --- a/wake_alarm/_smart_plug.py +++ /dev/null @@ -1,155 +0,0 @@ -"""TP-Link Tapo P110 smart-plug control for the wake alarm. - -Config file ``~/.config/wake_alarm/tapo.json`` (mode 0600) must contain:: - - { - "host": "192.168.x.x", - "email": "tapo-account@example.com", - "password": "tapo-account-password", - } - -If the file is missing, malformed, the ``kasa`` package is unavailable, or -the plug cannot be reached within :data:`TAPO_TIMEOUT_SECONDS`, the -operation is skipped with a WARNING log entry — the alarm must never -block on the plug. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import json -import logging -from typing import TYPE_CHECKING - -from wake_alarm._constants import ( - TAPO_CONFIG_FILE, - TAPO_TIMEOUT_SECONDS, -) - -if TYPE_CHECKING: - from kasa import Device - -_logger = logging.getLogger(__name__) - -# ``kasa`` is an optional runtime dependency. Import at module load time so -# we fail fast if it is missing rather than re-importing on every call. -try: - from kasa import Credentials, Discover - from kasa.exceptions import KasaException - - _KASA_AVAILABLE = True -except ImportError: - _KASA_AVAILABLE = False - _logger.warning( - "python-kasa is not installed; Tapo smart-plug control disabled", - ) - - -def _load_config() -> dict[str, str] | None: - """Return validated Tapo config from :data:`TAPO_CONFIG_FILE`, or ``None``. - - Returns: - ``None`` if the file is missing, unreadable, malformed, or missing - any of the required keys. Otherwise a dict with ``host``, ``email``, - ``password``. - """ - try: - with TAPO_CONFIG_FILE.open(encoding="utf-8") as fh: - data = json.load(fh) - except FileNotFoundError: - _logger.warning( - "Tapo config %s does not exist; smart-plug control disabled", - TAPO_CONFIG_FILE, - ) - return None - except OSError, json.JSONDecodeError: - _logger.warning( - "Tapo config %s is unreadable or malformed; skipping plug control", - TAPO_CONFIG_FILE, - exc_info=True, - ) - return None - if not isinstance(data, dict): - _logger.warning( - "Tapo config %s is not a JSON object; skipping plug control", - TAPO_CONFIG_FILE, - ) - return None - required = ("host", "email", "password") - if not all(isinstance(data.get(k), str) and data[k] for k in required): - _logger.warning( - "Tapo config %s missing required keys %s; skipping plug control", - TAPO_CONFIG_FILE, - required, - ) - return None - return {k: data[k] for k in required} - - -async def _connect(config: dict[str, str]) -> Device | None: - """Open a connection to the configured plug, or ``None`` on failure.""" - try: - dev = await Discover.discover_single( - config["host"], - credentials=Credentials(config["email"], config["password"]), - ) - except TimeoutError, KasaException, OSError: - _logger.warning("Tapo plug discovery failed", exc_info=True) - return None - try: - await dev.update() - except TimeoutError, KasaException, OSError: - _logger.warning("Tapo plug update failed", exc_info=True) - with contextlib.suppress(KasaException, OSError): - await dev.disconnect() - return None - return dev - - -async def _set_state(*, on: bool) -> None: - """Connect to the plug and set its on/off state.""" - config = _load_config() - if config is None: - return - dev = await _connect(config) - if dev is None: - return - try: - if on: - await dev.turn_on() - else: - await dev.turn_off() - except TimeoutError, KasaException, OSError: - _logger.warning("Tapo plug toggle failed", exc_info=True) - finally: - with contextlib.suppress(KasaException, OSError): - await dev.disconnect() - - -def _run(*, on: bool) -> None: - """Run :func:`_set_state` with a hard timeout. Never raises.""" - if not _KASA_AVAILABLE: - _logger.warning( - "python-kasa unavailable; skipping Tapo plug %s", - "ON" if on else "OFF", - ) - return - - async def _runner() -> None: - await asyncio.wait_for(_set_state(on=on), timeout=TAPO_TIMEOUT_SECONDS) - - try: - asyncio.run(_runner()) - except TimeoutError, OSError, RuntimeError: - _logger.warning("Tapo plug control timed out or failed", exc_info=True) - - -def turn_on_plug() -> None: - """Turn the configured Tapo plug on. Logs a WARNING if not configured.""" - _run(on=True) - - -def turn_off_plug() -> None: - """Turn the configured Tapo plug off. Logs a WARNING if not configured.""" - _run(on=False) diff --git a/wake_alarm/_startup.py b/wake_alarm/_startup.py deleted file mode 100644 index dfb0bd5..0000000 --- a/wake_alarm/_startup.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Bring the machine's hardware up before the alarm window appears. - -Split out of ``_alarm.py`` for the 250-line cap. - -Nothing here blocks waiting for the monitor. It powers things on and returns; -the beep loop's per-cycle sink check and ``_reactivate``'s retry schedule pick -up outputs as they enumerate, which is a real presence check rather than a -timed guess. The curtains are the same shape: fire and forget, never block. -""" - -from __future__ import annotations - -from collections.abc import Callable -import logging -import time - -from wake_alarm._alarm_display import _wake_display -from wake_alarm._brightness import _set_max_brightness -from wake_alarm._constants import PLUG_PREROLL_SECONDS -from wake_alarm._curtains import open_curtains -from wake_alarm._sinks import _warn_if_no_real_sink -from wake_alarm._smart_plug import turn_on_plug - -_logger = logging.getLogger(__name__) - -_Sleep = Callable[[float], object] - - -def prepare_hardware(*, sleep: _Sleep = time.sleep) -> None: - """Wake the display, raise brightness and power the speakers. - - Args: - sleep: Delay function, injected so tests do not really wait. - """ - _warn_if_no_real_sink() - _wake_display() - _set_max_brightness() - # Power the speakers BEFORE the first beep. An amplifier's soft-start / - # mute relay swallows the first seconds of audio after mains is applied, - # so firing the plug and the first tone together loses the opening of the - # alarm entirely. Costs nothing when no plug is configured. - turn_on_plug() - # Daylight is the one wake cue that outlives the audio failsafe: the sound - # stops at FAILSAFE_SILENCE_SECONDS, an open curtain does not. No-op until - # hardware exists, but the call site and the log event are real now. - open_curtains() - _logger.info( - "Waiting %.1fs for speaker soft-start before the first beep", - PLUG_PREROLL_SECONDS, - extra={"event": "plug_preroll", "seconds": PLUG_PREROLL_SECONDS}, - ) - sleep(PLUG_PREROLL_SECONDS) diff --git a/wake_alarm/_state.py b/wake_alarm/_state.py index dc77ed8..16985f2 100644 --- a/wake_alarm/_state.py +++ b/wake_alarm/_state.py @@ -1,4 +1,13 @@ -"""HMAC-signed state management for the weekend wake alarm.""" +"""HMAC-signed record of whether the trigger already fired today. + +The PC no longer wakes anyone, so this is not a record of a *dismissal* any +more — it is the trigger's once-per-day latch. It has to exist: the tick runs +every minute and the catch-up window is ``TRIGGER_CATCHUP_MINUTES`` wide, so +without a latch the morning routine would be restarted on every tick for the +rest of that window, relaunching the workout lock each time the user finished +it. The alarm's own dismissal used to provide that latch; deleting the alarm +without replacing it would have been a silent regression. +""" from __future__ import annotations @@ -11,7 +20,7 @@ from gatelock.log_integrity import ( verify_entry_hmac, ) -from wake_alarm._constants import WAKE_STATE_FILE, WORKOUT_LOG_FILE +from wake_alarm._constants import WAKE_STATE_FILE _logger = logging.getLogger(__name__) @@ -21,24 +30,18 @@ def _today_str() -> str: return datetime.now(tz=UTC).strftime("%Y-%m-%d") -def save_wake_state( - *, - dismissed_at: str | None, - skip_workout: bool, -) -> bool: - """Write today's wake state with HMAC signature. +def save_trigger_fired(*, fired_at: str) -> bool: + """Record that the morning routine was started today. Args: - dismissed_at: ISO time when alarm was dismissed, or None. - skip_workout: Whether the user earned a workout skip. + fired_at: ISO timestamp of the tick that started the routine. Returns: True if saved successfully, False otherwise. """ entry: dict[str, object] = { "date": _today_str(), - "dismissed_at": dismissed_at, - "skip_workout": skip_workout, + "fired_at": fired_at, } signature = compute_entry_hmac(entry) if signature is not None: @@ -53,16 +56,12 @@ def save_wake_state( _logger.warning("Failed to save wake state: %s", exc) return False - _logger.info( - "Saved wake state: dismissed=%s skip=%s", - dismissed_at, - skip_workout, - ) + _logger.info("Recorded trigger fired at %s", fired_at) return True def load_wake_state() -> dict[str, object] | None: - """Load and verify today's wake state. + """Load and verify today's trigger record. Returns the state dict if it exists, is valid (HMAC OK), and is for today. Returns None otherwise. @@ -90,40 +89,9 @@ def load_wake_state() -> dict[str, object] | None: return state -def has_workout_skip_today() -> bool: - """Check if the user earned a workout skip for today.""" +def was_trigger_fired_today() -> bool: + """Check whether the morning routine was already started today.""" state = load_wake_state() if state is None: return False - return bool(state.get("skip_workout")) - - -def was_alarm_dismissed_today() -> bool: - """Check if the alarm was already dismissed today.""" - state = load_wake_state() - if state is None: - return False - return state.get("dismissed_at") is not None - - -def was_workout_logged_today() -> bool: - """Check if the workout was already logged today via the screen locker. - - Reads the companion screen_locker workout_log.json. The file is a - dict keyed by YYYY-MM-DD date strings; presence of today's key means - the workout was completed and the alarm is no longer needed. - - Returns: - True if today's workout entry exists, False on any error or absence. - """ - if not WORKOUT_LOG_FILE.exists(): - return False - try: - with WORKOUT_LOG_FILE.open() as f: - log = json.load(f) - except OSError, json.JSONDecodeError: - _logger.warning("Cannot read workout log file %s", WORKOUT_LOG_FILE) - return False - if not isinstance(log, dict): - return False - return _today_str() in log + return state.get("fired_at") is not None diff --git a/wake_alarm/_tone.py b/wake_alarm/_tone.py deleted file mode 100644 index 7dd487e..0000000 --- a/wake_alarm/_tone.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Synthesise and cache the alarm tone. - -Split out of ``_audio.py`` for the 250-line cap. - -Note the pylint ``generated-members`` entries for ``wave.Wave_write`` in -pyproject.toml: pylint infers the read-mode overload of ``wave.open`` and -wrongly reports ``setnchannels`` and friends as missing. Keep them. -""" - -from __future__ import annotations - -import logging -import math -from pathlib import Path -import struct -import tempfile -import wave - -_logger = logging.getLogger(__name__) - -# Generated WAVs are reused for the life of the process. -_TONE_CACHE: dict[int, Path] = {} -_TONE_DURATION_SECONDS: float = 1.5 -_TONE_FRAMERATE: int = 48000 -_TONE_AMPLITUDE: int = 32760 # near s16 max for the loudest sine we can emit - - -def _ensure_tone_wav(frequency: int) -> Path: - """Generate (and cache) a mono 48 kHz sine WAV at *frequency* Hz.""" - cached = _TONE_CACHE.get(frequency) - if cached is not None and cached.exists(): - return cached - path = Path(tempfile.gettempdir()) / f"wake_alarm_tone_{frequency}.wav" - n_frames = int(_TONE_FRAMERATE * _TONE_DURATION_SECONDS) - with wave.open(str(path), "wb") as wav: - wav.setnchannels(1) - wav.setsampwidth(2) - wav.setframerate(_TONE_FRAMERATE) - frames = bytearray() - for i in range(n_frames): - sample = int( - _TONE_AMPLITUDE - * math.sin(2 * math.pi * frequency * i / _TONE_FRAMERATE), - ) - frames.extend(struct.pack(" str | None: """Return why the alarm must not fire right now, or None if it may. Checks only the gates that are independent of the clock: the day gate and - the two "already handled today" gates shared with ``_alarm.py``. + the once-per-day latch. Args: now: Current local time. @@ -93,10 +92,8 @@ def _skip_reason(now: datetime) -> str | None: """ if now.weekday() not in ALARM_DAYS: return "not an alarm day" - if was_alarm_dismissed_today(): - return "alarm already dismissed today" - if was_workout_logged_today(): - return "workout already logged today" + if was_trigger_fired_today(): + return "already fired today" return None @@ -140,15 +137,6 @@ def _decide(now: datetime, alarm: Alarm) -> tuple[bool, str]: if skip is not None: return False, skip - # An alarm that was cut short -- almost always a power-button press or a - # hard power-off -- left a "ringing" session record that nothing cleared. - # It re-fires regardless of the catch-up window, because the whole point is - # that the machine may have been off for the rest of that window. The day / - # dismissed / workout gates above still apply, and the record itself is - # age-bounded, so this cannot loop forever on a crashed alarm. - if should_refire(now=now): - return True, "an earlier alarm was cut short and never dismissed" - fire_at, window_end = _fire_window(alarm, now) if now < fire_at: return False, f"alarm {alarm} is still ahead" @@ -235,7 +223,12 @@ def main() -> None: ) _logger.warning("Firing %s: %s", TRIGGER_TARGET_UNIT, reason) - _start_target_unit() + if _start_target_unit(): + # Latch immediately. The tick runs every minute for the rest of the + # catch-up window, and the unit is Type=simple: a start while it is + # running is a no-op, but the moment the workout lock finishes the + # next tick would relaunch it on top of the user. + save_trigger_fired(fired_at=now.isoformat()) if __name__ == "__main__": diff --git a/wake_alarm/_view_proxies.py b/wake_alarm/_view_proxies.py deleted file mode 100644 index 709d281..0000000 --- a/wake_alarm/_view_proxies.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Fan-out proxies — one call, applied to the same widget on every monitor. - -Split out of ``_alarm_view.py`` for the 250-line cap. These are pure plumbing -over already-built widgets: nothing here constructs Tk, which is what makes -them separable from the builders that do. -""" - -from __future__ import annotations - -import contextlib - -# Runtime import, not TYPE_CHECKING: tk.TclError is caught in a suppress() -# below, so a deferred import would raise NameError the first time a widget -# call fails — which is exactly the path these proxies exist to swallow. -import tkinter as tk -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from gatelock import LockConfig - - from wake_alarm._alarm_view import _AlarmView - - -class _LabelProxy: - """Fans one ``configure`` call out to the same label on every monitor.""" - - def __init__(self, widgets: list[tk.Label]) -> None: - """Wrap the per-monitor copies of one label.""" - self._widgets = widgets - - def configure(self, **kwargs: str) -> None: - """Apply the same configuration to every copy.""" - for widget in self._widgets: - with contextlib.suppress(tk.TclError): - widget.configure(**kwargs) - - -class _EntryProxy: - """The dismiss-code entry, as seen across every monitor. - - All copies share one root-mastered ``StringVar``, so typing on whichever - screen the user is actually looking at is the same text everywhere -- and - reading it needs no idea of which monitor that was. - """ - - def __init__(self, widgets: list[tk.Entry], var: tk.StringVar) -> None: - """Wrap the per-monitor entries and the variable behind them.""" - self._widgets = widgets - self._var = var - - def get(self) -> str: - """Return the shared text, whichever monitor it was typed on.""" - return self._var.get() - - def delete(self, _first: object, _last: object = None) -> None: - """Clear the shared text on every monitor.""" - self._var.set("") - - def focus_force(self) -> None: - """Focus the first entry that still exists.""" - for widget in self._widgets: - with contextlib.suppress(tk.TclError): - widget.focus_force() - return - - -class _ViewGroup: - """Presents N per-monitor views as if they were one. - - Every label update fans out to all monitors, and the challenge can be - answered on any of them -- solving it anywhere dismisses the alarm - everywhere, because every entry is the same variable and every submit - button is the same callable. - """ - - def __init__(self, config: LockConfig, entry_var: tk.StringVar) -> None: - """Start empty; surfaces are added as outputs come up.""" - self.config = config - self._entry_var = entry_var - self._views: list[_AlarmView] = [] - - def add(self, view: _AlarmView) -> None: - """Register a newly built per-monitor view.""" - self._views.append(view) - - def discard(self, output_name: str) -> None: - """Forget the view for an output that went dark.""" - self._views = [v for v in self._views if v.output_name != output_name] - - @property - def surfaces(self) -> list[_AlarmView]: - """Every per-monitor view, for callers needing more than one widget.""" - return list(self._views) - - @property - def containers(self) -> list[tk.Frame]: - """Every per-monitor container frame.""" - return [view.container for view in self._views] - - @property - def entry_var(self) -> tk.StringVar: - """The one variable every monitor's entry is bound to.""" - return self._entry_var - - @property - def entry(self) -> _EntryProxy: - """The dismiss-code entry across all monitors.""" - return _EntryProxy([v.entry for v in self._views], self._entry_var) - - def _label(self, name: str) -> _LabelProxy: - """Build a proxy over one named label on every monitor.""" - return _LabelProxy([getattr(view.labels, name) for view in self._views]) - - @property - def title_label(self) -> _LabelProxy: - """The "WAKE UP!" heading on every monitor.""" - return self._label("title") - - @property - def round_label(self) -> _LabelProxy: - """The round counter on every monitor.""" - return self._label("round_counter") - - @property - def info_label(self) -> _LabelProxy: - """The challenge hint on every monitor.""" - return self._label("info") - - @property - def code_label(self) -> _LabelProxy: - """The challenge display on every monitor.""" - return self._label("code") - - @property - def status_label(self) -> _LabelProxy: - """The status line on every monitor.""" - return self._label("status") - - @property - def timer_label(self) -> _LabelProxy: - """The countdown on every monitor.""" - return self._label("timer") - - @property - def escape_buttons(self) -> list[tk.Button]: - """The escape-hatch button on every monitor.""" - return [view.escape_button for view in self._views] diff --git a/wake_alarm/tests/conftest.py b/wake_alarm/tests/conftest.py index c09d32f..592d07c 100644 --- a/wake_alarm/tests/conftest.py +++ b/wake_alarm/tests/conftest.py @@ -1,40 +1,26 @@ """Shared fixtures keeping the wake_alarm suite off the real machine. -Since gatelock v0.2.0 the lock builds one real ``tk.Toplevel`` per live -output. Two consequences for tests, both of which bite silently: +Two hazards are left now that the PC no longer owns a lock screen, audio or +an inhibitor: -* ``tk.Toplevel`` over a ``MagicMock`` master sends real tkinter into infinite - mock recursion -- the run **hangs** rather than failing, which is far harder - to diagnose than an error. -* An unpatched lock setup shells out to ``setxkbmap`` and really disables VT - switching on the machine running the tests, and reaches the real - ``$XDG_RUNTIME_DIR``, where it could stand a *production* locker down. - -Sink reactivation adds a fourth: ``WakeAlarm.__init__`` starts a background -thread that really shells out to ``pactl set-card-profile`` and would reshape -the *host's* audio routing mid-test. - -The power-key inhibitor and the session record add a fifth and a sixth: an -unstubbed run really spawns ``systemd-inhibit`` (blocking the machine's power -button for as long as the test process lives) and really writes the -*production* ``alarm_session.json``, which the next real trigger tick would -then act on. All three are stubbed here and re-patched explicitly by the tests -that assert the wiring. - -The escape hatch adds a third: ``WakeAlarm.__init__`` builds its tracker over -the real ``ESCAPE_HISTORY_FILE``, so an unpatched run both reads a *production* -budget -- making "the button appears" tests fail on any day the real budget is -spent -- and could write to it. - -So this is autouse and deliberately broad. +* ``_state`` signs its records with gatelock's HMAC key, and the real key is + root-owned but *world-readable*. An unpatched run would sign test entries + with the **production** key on this machine and with none at all anywhere + else, so the suite would pass here and behave differently in CI. +* ``WAKE_STATE_FILE`` is a real path inside the repo, and ``_trigger`` now + *writes* it. The trigger tests never redirected it, because until the + once-per-day latch moved into the trigger nothing on that path wrote + anything. Redirecting it here rather than per-file is deliberate: a + per-file redirect is exactly the kind that gets forgotten when a new writer + appears, and the failure is silent -- a green suite that quietly stamped + the live state file. """ from __future__ import annotations from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch +from unittest.mock import patch -from gatelock import Output, OutputRect import pytest if TYPE_CHECKING: @@ -42,83 +28,13 @@ if TYPE_CHECKING: from pathlib import Path -FAKE_OUTPUTS = ( - Output( - "VIRTUAL-1", - connected=True, - rect=OutputRect(0, 0, 1920, 1080), - primary=True, - ), -) -"""One live output, so the Tk-screen fallback (which would call int() on a -MagicMock) is never reached.""" - -TWO_OUTPUTS = ( - Output("DP-0", connected=True, rect=OutputRect(0, 0, 3840, 2160), primary=True), - Output("HDMI-0", connected=True, rect=OutputRect(3840, 0, 2560, 1440)), -) -"""The real desk. Opt into it with the ``dual_output`` fixture for anything -asserting that work fans out: against ``FAKE_OUTPUTS`` a loop that only ever -touched the first surface would pass every assertion.""" - - -def make_toplevel(_parent: object = None, **_kwargs: object) -> MagicMock: - """A Toplevel stand-in that remembers the geometry it was given.""" - window = MagicMock() - state = {"x": 0, "y": 0, "w": 0, "h": 0, "mapped": False} - - def geometry(spec: str) -> None: - size, x, y = spec.split("+") - width, height = size.split("x") - state.update(x=int(x), y=int(y), w=int(width), h=int(height)) - - window.geometry.side_effect = geometry - window.deiconify.side_effect = lambda: state.update(mapped=True) - window.withdraw.side_effect = lambda: state.update(mapped=False) - window.winfo_ismapped.side_effect = lambda: state["mapped"] - window.winfo_rootx.side_effect = lambda: state["x"] - window.winfo_rooty.side_effect = lambda: state["y"] - window.winfo_width.side_effect = lambda: state["w"] - window.winfo_height.side_effect = lambda: state["h"] - return window - - @pytest.fixture(autouse=True) -def _hermetic(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[None]: - """Block every path from the lock to the real display or machine.""" - monkeypatch.setenv("GATELOCK_RUNTIME_DIR", str(tmp_path / "gatelock-runtime")) +def _hermetic(tmp_path: Path) -> Generator[None]: + """Keep signing keys and state writes inside ``tmp_path``.""" key_file = tmp_path / "hmac.key" key_file.write_bytes(b"0" * 32) with ( - # The real key is root-owned but world-readable, so an unpatched run - # signs test entries with the *production* key on this machine and - # with none at all anywhere else. Pin it so the suite is portable. patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key_file), - patch( - "wake_alarm._escape_flow.ESCAPE_HISTORY_FILE", - tmp_path / "escape_history.json", - ), - patch("wake_alarm._alarm.start_alarm_reactivation"), - patch("wake_alarm._alarm.take_power_key_inhibitor", return_value=None), - patch("wake_alarm._alarm.release_power_key_inhibitor"), - patch("wake_alarm._alarm.start_session"), - patch("wake_alarm._alarm.finish_session"), - patch("gatelock._surfaces.tk.Toplevel", side_effect=make_toplevel), - patch("gatelock._outputs.RandrBackend.create", return_value=None), - patch("gatelock._outputs.scan_xrandr", return_value=FAKE_OUTPUTS), - patch("gatelock._detect._RandrEventSource.start", return_value=False), - patch("gatelock._vt.shutil.which", return_value="/usr/bin/setxkbmap"), - patch("gatelock._vt.subprocess.run"), + patch("wake_alarm._state.WAKE_STATE_FILE", tmp_path / "wake_state.json"), ): yield - - -@pytest.fixture -def dual_output(_hermetic: None) -> Generator[None]: - """Re-scan as a two-monitor desk, layered over the single-output default. - - Depends on ``_hermetic`` so pytest is obliged to apply it second; the - ``scan_xrandr`` patch here has to be the one that wins. - """ - with patch("gatelock._outputs.scan_xrandr", return_value=TWO_OUTPUTS): - yield diff --git a/wake_alarm/tests/test_alarm.py b/wake_alarm/tests/test_alarm.py deleted file mode 100644 index fb4d9da..0000000 --- a/wake_alarm/tests/test_alarm.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Tests for _alarm.py — wake alarm daemon, UI, and beep logic.""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Generator - -from wake_alarm._audio import ( - _beep_loud, - _beep_medium, - _beep_soft, - _speaker_test_path, -) -from wake_alarm._challenges import ( - _DISMISS_CHARS, - _generate_code, -) -from wake_alarm._constants import ( - DISMISS_CODE_LENGTH, -) -from wake_alarm._gating import ( - is_alarm_day as _is_alarm_day, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_mock_tk() -> MagicMock: - """Build a MagicMock that stands in for the tkinter module.""" - mock = MagicMock() - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - mock.Frame.return_value = MagicMock() - mock.Label.return_value = MagicMock() - mock.Entry.return_value = MagicMock() - mock.TclError = tk.TclError - mock.END = tk.END - return mock - - -@pytest.fixture(autouse=True) -def _block_real_tk() -> Generator[MagicMock]: - """Prevent any real Tk windows in tests.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -@pytest.fixture -def mock_tk_module() -> Generator[MagicMock]: - """Provide explicit access to the mocked tk module.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -# --------------------------------------------------------------------------- -# Unit tests for pure functions -# --------------------------------------------------------------------------- - - -class TestGenerateCode: - """Tests for _generate_code.""" - - def test_correct_length(self) -> None: - """Generated code has the configured length.""" - code = _generate_code() - assert len(code) == DISMISS_CODE_LENGTH - - def test_all_alphanumeric(self) -> None: - """Generated code uses only the unambiguous alphanumeric charset.""" - - code = _generate_code() - assert all(c in _DISMISS_CHARS for c in code) - - def test_different_codes(self) -> None: - """Two calls produce different codes (probabilistic, but safe).""" - codes = {_generate_code() for _ in range(50)} - assert len(codes) > 1 - - -class TestIsAlarmDay: - """Tests for _is_alarm_day.""" - - def test_monday_is_alarm_day(self) -> None: - """Monday (weekday=0) is an alarm day.""" - from datetime import datetime - - # Create a date that is Monday - with patch( - "wake_alarm._alarm.datetime", - ) as mock_dt: - mock_now = MagicMock() - mock_now.weekday.return_value = 0 # Monday - mock_dt.now.return_value = mock_now - mock_dt.side_effect = datetime - assert _is_alarm_day() is True - - def test_tuesday_is_alarm_day(self) -> None: - """Tuesday (weekday=1) is an alarm day.""" - with patch( - "wake_alarm._alarm.datetime", - ) as mock_dt: - mock_now = MagicMock() - mock_now.weekday.return_value = 1 # Tuesday - mock_dt.now.return_value = mock_now - assert _is_alarm_day() is True - - def test_friday_is_alarm_day(self) -> None: - """Friday (weekday=4) is an alarm day.""" - with patch( - "wake_alarm._alarm.datetime", - ) as mock_dt: - mock_now = MagicMock() - mock_now.weekday.return_value = 4 # Friday - mock_dt.now.return_value = mock_now - assert _is_alarm_day() is True - - def test_saturday_is_alarm_day(self) -> None: - """Saturday (weekday=5) is an alarm day.""" - with patch( - "wake_alarm._alarm.datetime", - ) as mock_dt: - mock_now = MagicMock() - mock_now.weekday.return_value = 5 - mock_dt.now.return_value = mock_now - assert _is_alarm_day() is True - - def test_sunday_is_alarm_day(self) -> None: - """Sunday (weekday=6) is an alarm day.""" - with patch( - "wake_alarm._alarm.datetime", - ) as mock_dt: - mock_now = MagicMock() - mock_now.weekday.return_value = 6 - mock_dt.now.return_value = mock_now - assert _is_alarm_day() is True - - def test_wednesday_is_alarm_day(self) -> None: - """Wednesday (weekday=2) is an alarm day.""" - with patch( - "wake_alarm._alarm.datetime", - ) as mock_dt: - mock_now = MagicMock() - mock_now.weekday.return_value = 2 - mock_dt.now.return_value = mock_now - assert _is_alarm_day() is True - - def test_thursday_is_alarm_day(self) -> None: - """Thursday (weekday=3) is an alarm day.""" - with patch( - "wake_alarm._alarm.datetime", - ) as mock_dt: - mock_now = MagicMock() - mock_now.weekday.return_value = 3 - mock_dt.now.return_value = mock_now - assert _is_alarm_day() is True - - -class TestSpeakerTestPath: - """Tests for _speaker_test_path.""" - - def test_returns_path_when_found(self) -> None: - """Return full path when speaker-test is available.""" - with patch( - "wake_alarm._audio.shutil.which", - return_value="/usr/bin/speaker-test", - ): - assert _speaker_test_path() == "/usr/bin/speaker-test" - - def test_raises_when_not_found(self) -> None: - """Raise FileNotFoundError when speaker-test is missing.""" - with ( - patch( - "wake_alarm._audio.shutil.which", - return_value=None, - ), - pytest.raises(FileNotFoundError, match="speaker-test not found"), - ): - _speaker_test_path() - - -class TestBeepFunctions: - """Tests for beep helper functions.""" - - def test_beep_soft_writes_bell(self) -> None: - """_beep_soft writes terminal bell character.""" - with patch("wake_alarm._audio.sys") as mock_sys: - mock_sys.stdout = MagicMock() - _beep_soft() - mock_sys.stdout.write.assert_called_once_with("\a") - mock_sys.stdout.flush.assert_called_once() - - def test_beep_medium_delegates_to_play_tone(self) -> None: - """_beep_medium just delegates to _play_tone.""" - with patch("wake_alarm._audio._play_tone") as mock_play: - _beep_medium(frequency=800) - mock_play.assert_called_once_with(800) - - def test_beep_loud_delegates_to_play_tone(self) -> None: - """_beep_loud just delegates to _play_tone.""" - with patch("wake_alarm._audio._play_tone") as mock_play: - _beep_loud(frequency=1200) - mock_play.assert_called_once_with(1200) diff --git a/wake_alarm/tests/test_alarm_audio.py b/wake_alarm/tests/test_alarm_audio.py deleted file mode 100644 index 86bdc99..0000000 --- a/wake_alarm/tests/test_alarm_audio.py +++ /dev/null @@ -1,234 +0,0 @@ -"""Tests for _audio.py — audio playback, fan control, and sink management.""" - -from __future__ import annotations - -import subprocess -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Iterator - import pathlib - -from wake_alarm._audio import ( - _play_tone, - _try_player, -) - - -class TestTryPlayer: - """Tests for _try_player.""" - - def test_returns_false_when_binary_missing( - self, - tmp_path: pathlib.Path, - ) -> None: - """Missing binary returns False without raising.""" - wav = tmp_path / "x.wav" - wav.write_bytes(b"\x00") - with patch( - "wake_alarm._audio.shutil.which", - return_value=None, - ): - assert _try_player("paplay", wav) is False - - def test_returns_true_on_success(self, tmp_path: pathlib.Path) -> None: - """Zero exit code returns True.""" - wav = tmp_path / "x.wav" - wav.write_bytes(b"\x00") - result = MagicMock() - result.returncode = 0 - with ( - patch( - "wake_alarm._audio.shutil.which", - return_value="/usr/bin/paplay", - ), - patch( - "wake_alarm._audio.subprocess.run", - return_value=result, - ), - ): - assert _try_player("paplay", wav) is True - - def test_returns_false_on_nonzero_exit( - self, - tmp_path: pathlib.Path, - ) -> None: - """Non-zero exit code returns False and logs.""" - wav = tmp_path / "x.wav" - wav.write_bytes(b"\x00") - result = MagicMock() - result.returncode = 1 - result.stderr = b"boom" - with ( - patch( - "wake_alarm._audio.shutil.which", - return_value="/usr/bin/paplay", - ), - patch( - "wake_alarm._audio.subprocess.run", - return_value=result, - ), - ): - assert _try_player("paplay", wav) is False - - def test_returns_false_on_timeout(self, tmp_path: pathlib.Path) -> None: - """TimeoutExpired returns False and logs.""" - wav = tmp_path / "x.wav" - wav.write_bytes(b"\x00") - with ( - patch( - "wake_alarm._audio.shutil.which", - return_value="/usr/bin/paplay", - ), - patch( - "wake_alarm._audio.subprocess.run", - side_effect=subprocess.TimeoutExpired("paplay", 6), - ), - ): - assert _try_player("paplay", wav) is False - - def test_returns_false_on_oserror(self, tmp_path: pathlib.Path) -> None: - """OSError returns False and logs.""" - wav = tmp_path / "x.wav" - wav.write_bytes(b"\x00") - with ( - patch( - "wake_alarm._audio.shutil.which", - return_value="/usr/bin/paplay", - ), - patch( - "wake_alarm._audio.subprocess.run", - side_effect=OSError("nope"), - ), - ): - assert _try_player("paplay", wav) is False - - -class TestPlayTone: - """Tests for _play_tone.""" - - @pytest.fixture(autouse=True) - def _silence_pcspkr(self) -> Iterator[None]: - """Stop tests from hitting the real /dev/input PC speaker device.""" - with patch("wake_alarm._audio._beep_pcspkr"): - yield - - def test_paplay_success_short_circuits(self, tmp_path: pathlib.Path) -> None: - """If paplay succeeds, no further players are tried.""" - wav = tmp_path / "tone.wav" - wav.write_bytes(b"\x00") - with ( - patch( - "wake_alarm._audio._ensure_tone_wav", - return_value=wav, - ), - patch( - "wake_alarm._audio._try_player", - return_value=True, - ) as mock_try, - patch( - "wake_alarm._audio.subprocess.run", - ) as mock_run, - ): - _play_tone(440) - mock_try.assert_called_once_with("paplay", wav) - mock_run.assert_not_called() - - def test_falls_back_to_aplay_then_speaker_test( - self, - tmp_path: pathlib.Path, - ) -> None: - """paplay+aplay fail → speaker-test is tried.""" - wav = tmp_path / "tone.wav" - wav.write_bytes(b"\x00") - with ( - patch( - "wake_alarm._audio._ensure_tone_wav", - return_value=wav, - ), - patch( - "wake_alarm._audio._try_player", - return_value=False, - ), - patch( - "wake_alarm._audio._speaker_test_path", - return_value="/usr/bin/speaker-test", - ), - patch( - "wake_alarm._audio.subprocess.run", - ) as mock_run, - ): - _play_tone(1000) - mock_run.assert_called_once() - args = mock_run.call_args[0][0] - assert "/usr/bin/speaker-test" in args - assert "1000" in args - - def test_soft_beep_when_speaker_test_missing( - self, - tmp_path: pathlib.Path, - ) -> None: - """All players fail → soft beep.""" - wav = tmp_path / "tone.wav" - wav.write_bytes(b"\x00") - with ( - patch( - "wake_alarm._audio._ensure_tone_wav", - return_value=wav, - ), - patch( - "wake_alarm._audio._try_player", - return_value=False, - ), - patch( - "wake_alarm._audio._speaker_test_path", - side_effect=FileNotFoundError("missing"), - ), - patch("wake_alarm._audio._beep_soft") as mock_soft, - ): - _play_tone(800) - mock_soft.assert_called_once() - - def test_soft_beep_when_speaker_test_times_out( - self, - tmp_path: pathlib.Path, - ) -> None: - """speaker-test TimeoutExpired → soft beep.""" - wav = tmp_path / "tone.wav" - wav.write_bytes(b"\x00") - with ( - patch( - "wake_alarm._audio._ensure_tone_wav", - return_value=wav, - ), - patch( - "wake_alarm._audio._try_player", - return_value=False, - ), - patch( - "wake_alarm._audio._speaker_test_path", - return_value="/usr/bin/speaker-test", - ), - patch( - "wake_alarm._audio.subprocess.run", - side_effect=subprocess.TimeoutExpired("speaker-test", 6), - ), - patch("wake_alarm._audio._beep_soft") as mock_soft, - ): - _play_tone(800) - mock_soft.assert_called_once() - - def test_soft_beep_when_wav_generation_fails(self) -> None: - """OSError generating WAV → soft beep.""" - with ( - patch( - "wake_alarm._audio._ensure_tone_wav", - side_effect=OSError("disk full"), - ), - patch("wake_alarm._audio._beep_soft") as mock_soft, - ): - _play_tone(440) - mock_soft.assert_called_once() diff --git a/wake_alarm/tests/test_alarm_challenges.py b/wake_alarm/tests/test_alarm_challenges.py deleted file mode 100644 index bf24e58..0000000 --- a/wake_alarm/tests/test_alarm_challenges.py +++ /dev/null @@ -1,245 +0,0 @@ -"""Tests for _challenges.py — dismiss challenge generators.""" - -from __future__ import annotations - -from unittest.mock import patch - -from wake_alarm._challenges import ( - _DISMISS_CHARS, - _REVERSE_WORDS, - _Challenge, - _make_challenge, - _make_flash_challenge, - _make_math_challenge, - _make_reverse_challenge, - _make_sequence_challenge, - _make_sort_challenge, -) -from wake_alarm._constants import DISMISS_FLASH_SECONDS - - -class TestMakeMathChallenge: - """Tests for _make_math_challenge.""" - - def test_kind_is_math(self) -> None: - """Challenge kind is always 'math'.""" - assert _make_math_challenge().kind == "math" - - def test_answer_is_correct_for_addition(self) -> None: - """Stored answer is numerically correct for addition.""" - with ( - patch("wake_alarm._challenges.secrets.choice", return_value="+"), - patch( - "wake_alarm._challenges.secrets.randbelow", - side_effect=[100, 200], # 100+100=200, 100+200=300 - ), - ): - ch = _make_math_challenge() - assert ch.display == "200 + 300 = ?" - assert ch.answer == "500" - - def test_answer_is_correct_for_subtraction(self) -> None: - """Stored answer is numerically correct for subtraction.""" - with ( - patch("wake_alarm._challenges.secrets.choice", return_value="-"), - patch( - "wake_alarm._challenges.secrets.randbelow", - side_effect=[100, 50], # 200+100=300, 100+50=150 - ), - ): - ch = _make_math_challenge() - assert ch.display == "300 - 150 = ?" - assert ch.answer == "150" - - def test_answer_is_correct_for_multiplication(self) -> None: - """Stored answer is numerically correct for multiplication.""" - with ( - patch("wake_alarm._challenges.secrets.choice", return_value="*"), - patch( - "wake_alarm._challenges.secrets.randbelow", - side_effect=[3, 4], # 12+3=15, 3+4=7 - ), - ): - ch = _make_math_challenge() - assert ch.display == "15 * 7 = ?" - assert ch.answer == "105" - - def test_answer_is_correct_for_division(self) -> None: - """Stored answer is numerically correct for division (always integer).""" - with ( - patch("wake_alarm._challenges.secrets.choice", return_value="÷"), - patch( - "wake_alarm._challenges.secrets.randbelow", - side_effect=[2, 5], # b=3+2=5, a=(10+5)*5=75 - ), - ): - ch = _make_math_challenge() - assert ch.display == "75 ÷ 5 = ?" - assert ch.answer == "15" - - def test_division_answer_is_always_integer(self) -> None: - """Division challenges always produce whole-number answers.""" - for _ in range(30): - with patch("wake_alarm._challenges.secrets.choice", return_value="÷"): - ch = _make_math_challenge() - # Extract dividend and divisor from display - parts = ch.display.replace(" ÷ ", " ").replace(" = ?", "").split() - dividend, divisor = int(parts[0]), int(parts[1]) - assert dividend % divisor == 0 - - def test_answer_varies_across_calls(self) -> None: - """Multiple calls produce varied answers (probabilistic).""" - answers = {_make_math_challenge().answer for _ in range(30)} - assert len(answers) > 1 - - -class TestMakeSortChallenge: - """Tests for _make_sort_challenge.""" - - def test_kind_is_sort(self) -> None: - """Challenge kind is always 'sort'.""" - assert _make_sort_challenge().kind == "sort" - - def test_answer_is_sorted_digits(self) -> None: - """Answer equals the digits in display sorted ascending.""" - ch = _make_sort_challenge() - displayed_digits = [int(c) for c in ch.display if c.isdigit()] - expected = "".join(str(d) for d in sorted(displayed_digits)) - assert ch.answer == expected - - def test_display_contains_eight_digits(self) -> None: - """Display always contains exactly eight digit characters.""" - ch = _make_sort_challenge() - assert len([c for c in ch.display if c.isdigit()]) == 8 - - def test_answer_varies_across_calls(self) -> None: - """Multiple calls produce varied digit sets.""" - answers = {_make_sort_challenge().answer for _ in range(30)} - assert len(answers) > 1 - - -class TestMakeFlashChallenge: - """Tests for _make_flash_challenge.""" - - def test_kind_is_flash(self) -> None: - """Challenge kind is always 'flash'.""" - assert _make_flash_challenge().kind == "flash" - - def test_display_equals_answer(self) -> None: - """Display and answer are identical (the user must recall the full code).""" - ch = _make_flash_challenge() - assert ch.display == ch.answer - - def test_code_uses_dismiss_chars(self) -> None: - """Generated code only contains chars from _DISMISS_CHARS.""" - ch = _make_flash_challenge() - assert all(c in _DISMISS_CHARS for c in ch.answer) - - def test_hint_mentions_flash_seconds(self) -> None: - """Hint text includes the number of visible seconds.""" - ch = _make_flash_challenge() - assert str(DISMISS_FLASH_SECONDS) in ch.hint - - -class TestMakeSequenceChallenge: - """Tests for _make_sequence_challenge.""" - - def test_kind_is_sequence(self) -> None: - """Challenge kind is always 'sequence'.""" - assert _make_sequence_challenge().kind == "sequence" - - def test_arithmetic_answer_is_correct(self) -> None: - """Arithmetic sequence next term is first + step * 4.""" - with patch("wake_alarm._challenges.secrets.randbelow", side_effect=[0, 0, 3]): - # randbelow(2)==0 → arithmetic, start=2+0=2, step=2+3=5 - ch = _make_sequence_challenge() - assert ch.display == "2, 7, 12, 17, ?" - assert ch.answer == "22" - - def test_geometric_answer_is_correct(self) -> None: - """Geometric sequence next term is start * ratio^4.""" - with patch("wake_alarm._challenges.secrets.randbelow", side_effect=[1, 0, 0]): - # randbelow(2)==1 → geometric, start=2+0=2, ratio=2+0=2 - ch = _make_sequence_challenge() - assert ch.display == "2, 4, 8, 16, ?" - assert ch.answer == "32" - - def test_answer_is_last_term(self) -> None: - """The answer is always the 5th term of the displayed sequence.""" - for _ in range(30): - ch = _make_sequence_challenge() - # Re-derive from the display - terms_str = ch.display.replace(", ?", "").split(", ") - terms = [int(t) for t in terms_str] - assert len(terms) == 4 - # Arithmetic check: constant difference - diffs = [terms[i + 1] - terms[i] for i in range(3)] - if len(set(diffs)) == 1: - expected = terms[-1] + diffs[0] - else: - # Geometric: constant ratio - ratio = terms[1] // terms[0] - expected = terms[-1] * ratio - assert ch.answer == str(expected) - - def test_both_series_types_reachable(self) -> None: - """Both arithmetic and geometric series appear across many calls.""" - found_arithmetic = False - found_geometric = False - for _ in range(200): - ch = _make_sequence_challenge() - terms_str = ch.display.replace(", ?", "").split(", ") - terms = [int(t) for t in terms_str] - diffs = [terms[i + 1] - terms[i] for i in range(3)] - if len(set(diffs)) == 1: - found_arithmetic = True - else: - found_geometric = True - if found_arithmetic and found_geometric: - break - assert found_arithmetic - assert found_geometric - - -class TestMakeReverseChallenge: - """Tests for _make_reverse_challenge.""" - - def test_kind_is_reverse(self) -> None: - """Challenge kind is always 'reverse'.""" - assert _make_reverse_challenge().kind == "reverse" - - def test_answer_is_word_reversed(self) -> None: - """Answer equals the displayed word reversed.""" - ch = _make_reverse_challenge() - # Extract the word from display (second line) - word = ch.display.split("\n")[1] - assert ch.answer == word[::-1] - - def test_word_is_from_pool(self) -> None: - """Displayed word always comes from _REVERSE_WORDS.""" - ch = _make_reverse_challenge() - word = ch.display.split("\n")[1] - assert word in _REVERSE_WORDS - - def test_answer_varies_across_calls(self) -> None: - """Multiple calls produce different words (probabilistic).""" - answers = {_make_reverse_challenge().answer for _ in range(60)} - assert len(answers) > 1 - - -class TestMakeChallenge: - """Tests for _make_challenge (the random dispatcher).""" - - def test_returns_a_challenge(self) -> None: - """Returns a _Challenge instance with all expected fields populated.""" - ch = _make_challenge() - assert isinstance(ch, _Challenge) - assert ch.kind in ("math", "flash", "sort", "sequence", "reverse") - assert ch.display - assert ch.answer - assert ch.hint - - def test_all_types_reachable(self) -> None: - """All five challenge types appear across many calls.""" - kinds = {_make_challenge().kind for _ in range(500)} - assert kinds == {"math", "flash", "sort", "sequence", "reverse"} diff --git a/wake_alarm/tests/test_alarm_dismiss.py b/wake_alarm/tests/test_alarm_dismiss.py deleted file mode 100644 index 763bbc4..0000000 --- a/wake_alarm/tests/test_alarm_dismiss.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Tests for the dismiss-challenge rounds.""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Generator - -from wake_alarm._alarm import ( - WakeAlarm, -) - -# --------------------------------------------------------------------------- -# Helpers (duplicated from part 1 so this file is self-contained) -# --------------------------------------------------------------------------- - - -# Helpers (duplicated from part 1 so this file is self-contained) -# --------------------------------------------------------------------------- - - -def _make_mock_tk() -> MagicMock: - """Build a MagicMock that stands in for the tkinter module.""" - mock = MagicMock() - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - # Distinct frames and buttons per call: a shared return_value would make - # "every monitor got one" indistinguishable from "one monitor got two". - mock.Frame.side_effect = lambda *_args, **_kwargs: MagicMock() - mock.Button.side_effect = lambda *_args, **_kwargs: MagicMock() - mock.Label.return_value = MagicMock() - mock.Entry.return_value = MagicMock() - mock.TclError = tk.TclError - mock.END = tk.END - return mock - - -@pytest.fixture(autouse=True) -def _block_real_tk() -> Generator[MagicMock]: - """Prevent any real Tk windows in tests.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -@pytest.fixture(autouse=True) -def _block_extra_devices() -> Generator[MagicMock]: - """Prevent real subprocess.Popen calls for extra ALSA devices.""" - with ( - patch("wake_alarm._beep_loop._play_on_all_sinks") as mock, - patch("wake_alarm._startup._set_max_brightness"), - patch("wake_alarm._startup._wake_display"), - patch("wake_alarm._alarm._restore_display"), - patch("wake_alarm._startup._warn_if_no_real_sink"), - patch("wake_alarm._alarm._activate_all_outputs", return_value=None), - patch("wake_alarm._alarm._restore_audio_state"), - patch("wake_alarm._startup.turn_on_plug"), - patch("wake_alarm._alarm.turn_off_plug"), - ): - yield mock - - -@pytest.fixture -def mock_tk_module() -> Generator[MagicMock]: - """Provide explicit access to the mocked tk module.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -class TestWakeAlarmDismiss: - """Tests for alarm dismiss logic.""" - - def test_correct_code_dismisses_after_all_rounds( - self, - mock_tk_module: MagicMock, - ) -> None: - """Entering the correct answer for every required round dismisses the alarm.""" - from wake_alarm._constants import DISMISS_ROUNDS_REQUIRED - - alarm = WakeAlarm(demo_mode=True) - - with patch( - "wake_alarm._alarm.save_wake_state", - ) as mock_save: - for _ in range(DISMISS_ROUNDS_REQUIRED): - alarm._view.entry_var.get.return_value = ( - alarm._progress.current_challenge.answer - ) - alarm._on_submit() - - assert alarm.dismissed is True - mock_save.assert_called_once() - assert mock_save.call_args[1]["skip_workout"] is True - alarm._stop_beep.set() - - def test_first_round_correct_does_not_dismiss( - self, - mock_tk_module: MagicMock, - ) -> None: - """A single correct entry is not enough — DISMISS_ROUNDS_REQUIRED is 2+.""" - alarm = WakeAlarm(demo_mode=True) - alarm._view.entry_var.get.return_value = ( - alarm._progress.current_challenge.answer - ) - - alarm._on_submit() - - assert alarm.dismissed is False - assert alarm._progress.rounds_completed == 1 - alarm._stop_beep.set() - - def test_first_round_correct_non_flash_next_no_countdown( - self, - mock_tk_module: MagicMock, - ) -> None: - """When next challenge is math, no flash countdown is started.""" - from wake_alarm._challenges import _Challenge - - alarm = WakeAlarm(demo_mode=True) - alarm._view.entry_var.get.return_value = ( - alarm._progress.current_challenge.answer - ) - next_math = _Challenge(kind="math", display="2 + 2 = ?", answer="4", hint="x") - with patch("wake_alarm._alarm._make_challenge", return_value=next_math): - alarm._on_submit() - - assert alarm._progress.current_challenge.kind == "math" - assert alarm.dismissed is False - alarm._stop_beep.set() - - def test_wrong_code_does_not_dismiss( - self, - mock_tk_module: MagicMock, - ) -> None: - """Entering the wrong answer shows an error without dismissing.""" - from wake_alarm._challenges import _Challenge - - alarm = WakeAlarm(demo_mode=True) - # Use a pinned math challenge so the non-flash wrong-answer branch is covered. - alarm._progress.current_challenge = _Challenge( - kind="math", display="2 + 2 = ?", answer="4", hint="test" - ) - alarm._view.entry_var.get.return_value = "99" - - alarm._on_submit() - - assert alarm.dismissed is False - alarm._stop_beep.set() - - def test_skip_window_expired_keeps_alarm_running( - self, - mock_tk_module: MagicMock, - ) -> None: - """Skip-window expiry denies the skip but does NOT stop the alarm.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - - with patch( - "wake_alarm._alarm.save_wake_state", - ) as mock_save: - alarm._on_skip_window_expired() - - # Alarm stays active and audible; only the skip reward is gone. - assert alarm._progress.skip_earnable is False - assert alarm._active is True - assert alarm.dismissed is False - assert not alarm._stop_beep.is_set() - mock_save.assert_not_called() - alarm._view._views[0].labels.info.configure.assert_called() - alarm._stop_beep.set() - - 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._progress.skip_earnable is True - alarm._stop_beep.set() - - def test_dismiss_after_skip_window_earns_no_skip( - self, - mock_tk_module: MagicMock, - ) -> None: - """Typing all rounds after the skip window stops the alarm without a skip.""" - from wake_alarm._constants import DISMISS_ROUNDS_REQUIRED - - alarm = WakeAlarm(demo_mode=True) - alarm._progress.skip_earnable = False - - with patch( - "wake_alarm._alarm.save_wake_state", - ) as mock_save: - for _ in range(DISMISS_ROUNDS_REQUIRED): - alarm._view.entry_var.get.return_value = ( - alarm._progress.current_challenge.answer - ) - alarm._on_submit() - - assert alarm.dismissed is True - assert mock_save.call_args[1]["skip_workout"] is False - alarm._stop_beep.set() diff --git a/wake_alarm/tests/test_alarm_display.py b/wake_alarm/tests/test_alarm_display.py deleted file mode 100644 index 426f348..0000000 --- a/wake_alarm/tests/test_alarm_display.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Tests for _alarm_display.py — DDC/CI and DPMS display power helpers.""" - -from __future__ import annotations - -import subprocess -from unittest.mock import MagicMock, patch - -from wake_alarm._alarm_display import ( - _ddcutil_power_on, - _restore_display, - _wake_display, -) - - -class TestDdcutilPowerOn: - """Tests for _ddcutil_power_on.""" - - def test_skips_when_ddcutil_missing(self) -> None: - """_ddcutil_power_on does nothing when ddcutil is not on PATH.""" - with ( - patch( - "wake_alarm._alarm_display.shutil.which", - return_value=None, - ), - patch("wake_alarm._alarm_display.subprocess.run") as mock_run, - ): - _ddcutil_power_on() - mock_run.assert_not_called() - - def test_runs_setvcp_when_ddcutil_present(self) -> None: - """_ddcutil_power_on sends setvcp D6 01 when ddcutil is found.""" - with ( - patch( - "wake_alarm._alarm_display.shutil.which", - return_value="/usr/bin/ddcutil", - ), - patch("wake_alarm._alarm_display.subprocess.run") as mock_run, - ): - _ddcutil_power_on() - mock_run.assert_called_once() - cmd = mock_run.call_args[0][0] - assert cmd == ["/usr/bin/ddcutil", "setvcp", "D6", "01"] - - def test_logs_success_when_returncode_zero(self) -> None: - """_ddcutil_power_on logs success when setvcp returns 0.""" - with ( - patch( - "wake_alarm._alarm_display.shutil.which", - return_value="/usr/bin/ddcutil", - ), - patch( - "wake_alarm._alarm_display.subprocess.run", - return_value=MagicMock(returncode=0), - ), - ): - _ddcutil_power_on() - - def test_handles_timeout(self) -> None: - """_ddcutil_power_on does not raise on TimeoutExpired.""" - with ( - patch( - "wake_alarm._alarm_display.shutil.which", - return_value="/usr/bin/ddcutil", - ), - patch( - "wake_alarm._alarm_display.subprocess.run", - side_effect=subprocess.TimeoutExpired(cmd="ddcutil", timeout=10), - ), - ): - _ddcutil_power_on() # must not raise - - def test_handles_oserror(self) -> None: - """_ddcutil_power_on does not raise on OSError.""" - with ( - patch( - "wake_alarm._alarm_display.shutil.which", - return_value="/usr/bin/ddcutil", - ), - patch( - "wake_alarm._alarm_display.subprocess.run", - side_effect=OSError("no device"), - ), - ): - _ddcutil_power_on() # must not raise - - -class TestDisplayHelpers: - """Tests for _wake_display and _restore_display when xset is absent.""" - - def test_wake_display_skips_when_xset_missing(self) -> None: - """_wake_display skips xset commands but still attempts ddcutil.""" - with ( - patch( - "wake_alarm._alarm_display.shutil.which", - return_value=None, - ), - patch("wake_alarm._alarm_display.subprocess.run") as mock_run, - ): - _wake_display() - mock_run.assert_not_called() - - def test_wake_display_runs_ddcutil_and_xset_commands(self) -> None: - """_wake_display runs ddcutil setvcp, xset dpms force on, xset s off.""" - with ( - patch( - "wake_alarm._alarm_display.shutil.which", - return_value="/usr/bin/xset", - ), - patch("wake_alarm._alarm_display.subprocess.run") as mock_run, - ): - _wake_display() - # 1 ddcutil setvcp call + 2 xset calls - assert mock_run.call_count == 3 - call_args = [call[0][0] for call in mock_run.call_args_list] - assert ["/usr/bin/xset", "setvcp", "D6", "01"] in call_args - assert ["/usr/bin/xset", "dpms", "force", "on"] in call_args - assert ["/usr/bin/xset", "s", "off"] in call_args - - def test_restore_display_skips_when_xset_missing(self) -> None: - """_restore_display does nothing when xset is not on PATH.""" - with ( - patch( - "wake_alarm._alarm_display.shutil.which", - return_value=None, - ), - patch("wake_alarm._alarm_display.subprocess.run") as mock_run, - ): - _restore_display() - mock_run.assert_not_called() - - def test_restore_display_runs_xset_s_on_when_present(self) -> None: - """_restore_display re-enables the screensaver via xset when present.""" - with ( - patch( - "wake_alarm._alarm_display.shutil.which", - return_value="/usr/bin/xset", - ), - patch("wake_alarm._alarm_display.subprocess.run") as mock_run, - ): - _restore_display() - mock_run.assert_called_once_with( - ["/usr/bin/xset", "s", "on"], - check=False, - capture_output=True, - timeout=5, - ) diff --git a/wake_alarm/tests/test_alarm_fanout.py b/wake_alarm/tests/test_alarm_fanout.py deleted file mode 100644 index df54fb4..0000000 --- a/wake_alarm/tests/test_alarm_fanout.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Tests that every action reaches all monitors.""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Generator - -from wake_alarm._alarm import WakeAlarm -from wake_alarm.tests.conftest import TWO_OUTPUTS - -# --------------------------------------------------------------------------- -# Helpers (duplicated from part 1 so this file is self-contained) -# --------------------------------------------------------------------------- - - -# Helpers (duplicated from part 1 so this file is self-contained) -# --------------------------------------------------------------------------- - - -def _make_mock_tk() -> MagicMock: - """Build a MagicMock that stands in for the tkinter module.""" - mock = MagicMock() - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - # Distinct frames and buttons per call: a shared return_value would make - # "every monitor got one" indistinguishable from "one monitor got two". - mock.Frame.side_effect = lambda *_args, **_kwargs: MagicMock() - mock.Button.side_effect = lambda *_args, **_kwargs: MagicMock() - mock.Label.return_value = MagicMock() - mock.Entry.return_value = MagicMock() - mock.TclError = tk.TclError - mock.END = tk.END - return mock - - -@pytest.fixture(autouse=True) -def _block_real_tk() -> Generator[MagicMock]: - """Prevent any real Tk windows in tests.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -@pytest.fixture(autouse=True) -def _block_extra_devices() -> Generator[MagicMock]: - """Prevent real subprocess.Popen calls for extra ALSA devices.""" - with ( - patch("wake_alarm._beep_loop._play_on_all_sinks") as mock, - patch("wake_alarm._startup._set_max_brightness"), - patch("wake_alarm._startup._wake_display"), - patch("wake_alarm._alarm._restore_display"), - patch("wake_alarm._startup._warn_if_no_real_sink"), - patch("wake_alarm._alarm._activate_all_outputs", return_value=None), - patch("wake_alarm._alarm._restore_audio_state"), - patch("wake_alarm._startup.turn_on_plug"), - patch("wake_alarm._alarm.turn_off_plug"), - ): - yield mock - - -@pytest.fixture -def mock_tk_module() -> Generator[MagicMock]: - """Provide explicit access to the mocked tk module.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -@pytest.mark.usefixtures("dual_output") -class TestFanOutAcrossMonitors: - """The three remaining v0.2.0 loops, on a desk that actually has two. - - Under the single-output default every one of these assertions holds even - if the loop body only ever ran for ``containers[0]`` -- which is exactly - the regression per-output locking exists to prevent. - """ - - def test_dismiss_clears_and_repaints_every_monitor( - self, mock_tk_module: MagicMock - ) -> None: - """The dismissal message replaces the challenge on both screens.""" - alarm = WakeAlarm(demo_mode=True) - containers = alarm._view.containers - assert len(containers) == len(TWO_OUTPUTS) - leftovers = {id(c): [MagicMock(), MagicMock()] for c in containers} - for container in containers: - container.winfo_children.return_value = leftovers[id(container)] - mock_tk_module.Label.reset_mock() - - with patch("wake_alarm._alarm.save_wake_state"): - alarm._dismiss_alarm(earned_skip=True) - - for widgets in leftovers.values(): - for widget in widgets: - widget.destroy.assert_called_once_with() - parents = [call.args[0] for call in mock_tk_module.Label.call_args_list] - assert {id(parent) for parent in parents} == {id(c) for c in containers} - alarm._stop_beep.set() - - def test_flash_recolours_every_monitor(self, mock_tk_module: MagicMock) -> None: - """A flash tick reaches both surfaces, not just the primary.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - containers = alarm._view.containers - # __init__ already ran one flash tick; only the next one should count. - for container in containers: - container.configure.reset_mock() - container.winfo_toplevel.return_value.configure.reset_mock() - - alarm._flash_step() - - for container in containers: - container.configure.assert_called_once() - container.winfo_toplevel.return_value.configure.assert_called_once() - alarm._stop_beep.set() diff --git a/wake_alarm/tests/test_alarm_flash_challenge.py b/wake_alarm/tests/test_alarm_flash_challenge.py deleted file mode 100644 index 919bf61..0000000 --- a/wake_alarm/tests/test_alarm_flash_challenge.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Tests for the flash-challenge dismiss round.""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Generator - -from wake_alarm._alarm import ( - WakeAlarm, -) - - -def _make_mock_tk() -> MagicMock: - """Build a MagicMock that stands in for the tkinter module.""" - mock = MagicMock() - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - mock.Frame.return_value = MagicMock() - mock.Label.return_value = MagicMock() - mock.Entry.return_value = MagicMock() - mock.TclError = tk.TclError - mock.END = tk.END - return mock - - -@pytest.fixture(autouse=True) -def _block_real_tk() -> Generator[MagicMock]: - """Prevent any real Tk windows in tests.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -@pytest.fixture(autouse=True) -def _block_extra_devices() -> Generator[MagicMock]: - """Prevent real subprocess calls for extra ALSA devices and hardware.""" - with ( - patch("wake_alarm._beep_loop._play_on_all_sinks") as mock, - patch("wake_alarm._startup._set_max_brightness"), - patch("wake_alarm._startup._wake_display"), - patch("wake_alarm._alarm._restore_display"), - patch("wake_alarm._startup._warn_if_no_real_sink"), - patch("wake_alarm._alarm._activate_all_outputs", return_value=None), - patch("wake_alarm._alarm._restore_audio_state"), - patch("wake_alarm._startup.turn_on_plug"), - patch("wake_alarm._alarm.turn_off_plug"), - ): - yield mock - - -@pytest.fixture -def mock_tk_module() -> Generator[MagicMock]: - """Provide explicit access to the mocked tk module.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -class TestFlashChallenge: - """Tests for flash challenge countdown behaviour inside WakeAlarm.""" - - def test_flash_tick_counts_down_and_hides( - self, - mock_tk_module: MagicMock, - ) -> None: - """_flash_tick counts down per second and hides the code at zero.""" - from wake_alarm._challenges import _Challenge - - alarm = WakeAlarm(demo_mode=True) - alarm._progress.current_challenge = _Challenge( - kind="flash", - display="ABCDEFGH", - answer="ABCDEFGH", - hint="Memorise", - ) - alarm._progress.flash_remaining = 2 - alarm._view._views[0].labels.status.configure.reset_mock() - - alarm._flash_tick() - assert alarm._progress.flash_remaining == 1 - alarm._view._views[0].labels.status.configure.assert_called() - - alarm._flash_tick() - assert alarm._progress.flash_remaining == 0 - - # Final tick hides the code. - alarm._flash_tick() - # _code_label and _status_label share the same mock; inspect all calls. - all_texts = [ - c.kwargs.get("text", "") - for c in alarm._view._views[0].labels.code.configure.call_args_list - ] - assert any("?" in t for t in all_texts) - alarm._stop_beep.set() - - def test_flash_tick_noop_when_inactive( - self, - mock_tk_module: MagicMock, - ) -> None: - """_flash_tick returns immediately when the alarm is no longer active.""" - alarm = WakeAlarm(demo_mode=True) - alarm._active = False - alarm._progress.flash_remaining = 3 - alarm._view._views[0].labels.status.configure.reset_mock() - - alarm._flash_tick() - - alarm._view._views[0].labels.status.configure.assert_not_called() - alarm._stop_beep.set() - - def test_wrong_flash_answer_reshows_code( - self, - mock_tk_module: MagicMock, - ) -> None: - """Wrong flash answer restores the code and restarts the countdown.""" - from wake_alarm._challenges import _Challenge - - alarm = WakeAlarm(demo_mode=True) - alarm._progress.current_challenge = _Challenge( - kind="flash", - display="TESTCODE", - answer="TESTCODE", - hint="Memorise", - ) - alarm._view.entry_var.get.return_value = "WRONGCODE" - alarm._view._views[0].labels.code.configure.reset_mock() - - alarm._on_submit() - - assert alarm.dismissed is False - # Code label should be reconfigured (code shown again + countdown restarted). - alarm._view._views[0].labels.code.configure.assert_called() - alarm._stop_beep.set() - - def test_next_round_flash_starts_countdown( - self, - mock_tk_module: MagicMock, - ) -> None: - """When the next-round challenge is flash, the countdown starts immediately.""" - from wake_alarm._challenges import _Challenge - - alarm = WakeAlarm(demo_mode=True) - alarm._progress.current_challenge = _Challenge( - kind="math", display="2 + 2 = ?", answer="4", hint="test" - ) - next_flash = _Challenge( - kind="flash", display="ABCDEFGH", answer="ABCDEFGH", hint="Memorise" - ) - alarm._view.entry_var.get.return_value = "4" - - with patch("wake_alarm._alarm._make_challenge", return_value=next_flash): - alarm._on_submit() - - assert alarm._progress.current_challenge.kind == "flash" - assert alarm.dismissed is False - alarm._stop_beep.set() diff --git a/wake_alarm/tests/test_alarm_main.py b/wake_alarm/tests/test_alarm_main.py deleted file mode 100644 index 90799cb..0000000 --- a/wake_alarm/tests/test_alarm_main.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Tests for the alarm daemon entry point.""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Generator - -from wake_alarm._alarm import ( - WakeAlarm, - main, -) - -# --------------------------------------------------------------------------- -# Helpers (duplicated from part 1 so this file is self-contained) -# --------------------------------------------------------------------------- - - -# Helpers (duplicated from part 1 so this file is self-contained) -# --------------------------------------------------------------------------- - - -def _make_mock_tk() -> MagicMock: - """Build a MagicMock that stands in for the tkinter module.""" - mock = MagicMock() - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - # Distinct frames and buttons per call: a shared return_value would make - # "every monitor got one" indistinguishable from "one monitor got two". - mock.Frame.side_effect = lambda *_args, **_kwargs: MagicMock() - mock.Button.side_effect = lambda *_args, **_kwargs: MagicMock() - mock.Label.return_value = MagicMock() - mock.Entry.return_value = MagicMock() - mock.TclError = tk.TclError - mock.END = tk.END - return mock - - -@pytest.fixture(autouse=True) -def _block_real_tk() -> Generator[MagicMock]: - """Prevent any real Tk windows in tests.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -@pytest.fixture(autouse=True) -def _block_extra_devices() -> Generator[MagicMock]: - """Prevent real subprocess.Popen calls for extra ALSA devices.""" - with ( - patch("wake_alarm._beep_loop._play_on_all_sinks") as mock, - patch("wake_alarm._startup._set_max_brightness"), - patch("wake_alarm._startup._wake_display"), - patch("wake_alarm._alarm._restore_display"), - patch("wake_alarm._startup._warn_if_no_real_sink"), - patch("wake_alarm._alarm._activate_all_outputs", return_value=None), - patch("wake_alarm._alarm._restore_audio_state"), - patch("wake_alarm._startup.turn_on_plug"), - patch("wake_alarm._alarm.turn_off_plug"), - ): - yield mock - - -@pytest.fixture -def mock_tk_module() -> Generator[MagicMock]: - """Provide explicit access to the mocked tk module.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -class TestMain: - """Tests for the main() entry point.""" - - def test_exits_when_not_alarm_day(self) -> None: - """main() returns early when not an alarm day.""" - with ( - patch( - "wake_alarm._alarm.should_run_alarm", - return_value=False, - ), - patch("wake_alarm._alarm.sys") as mock_sys, - ): - mock_sys.argv = ["alarm"] - main() # Should just return without error - - def test_creates_alarm_when_should_run( - self, - mock_tk_module: MagicMock, - ) -> None: - """main() creates a WakeAlarm when conditions are met.""" - del mock_tk_module - with ( - patch( - "wake_alarm._alarm.should_run_alarm", - return_value=True, - ), - patch( - "wake_alarm._alarm.sys", - ) as mock_sys, - patch.object(WakeAlarm, "run") as mock_run, - patch.object(WakeAlarm, "__init__", return_value=None), - ): - mock_sys.argv = ["alarm"] - main() - mock_run.assert_called_once() - - def test_trigger_now_bypasses_gate( - self, - mock_tk_module: MagicMock, - ) -> None: - """--trigger-now bypasses _should_run_alarm.""" - del mock_tk_module - with ( - patch( - "wake_alarm._alarm.should_run_alarm", - return_value=False, - ) as mock_gate, - patch("wake_alarm._alarm.sys") as mock_sys, - patch.object(WakeAlarm, "run") as mock_run, - patch.object(WakeAlarm, "__init__", return_value=None), - ): - mock_sys.argv = ["alarm", "--trigger-now"] - main() - mock_gate.assert_not_called() - mock_run.assert_called_once() diff --git a/wake_alarm/tests/test_alarm_part2.py b/wake_alarm/tests/test_alarm_part2.py deleted file mode 100644 index 2153e36..0000000 --- a/wake_alarm/tests/test_alarm_part2.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Tests for _alarm.py — WakeAlarm init, dismiss, run, and beep phases (part 2).""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Generator - -from wake_alarm._alarm import ( - WakeAlarm, -) - -# --------------------------------------------------------------------------- -# Helpers (duplicated from part 1 so this file is self-contained) -# --------------------------------------------------------------------------- - - -def _make_mock_tk() -> MagicMock: - """Build a MagicMock that stands in for the tkinter module.""" - mock = MagicMock() - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - mock.Frame.return_value = MagicMock() - mock.Label.return_value = MagicMock() - mock.Entry.return_value = MagicMock() - mock.TclError = tk.TclError - mock.END = tk.END - return mock - - -@pytest.fixture(autouse=True) -def _block_real_tk() -> Generator[MagicMock]: - """Prevent any real Tk windows in tests.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -@pytest.fixture(autouse=True) -def _block_extra_devices() -> Generator[MagicMock]: - """Prevent real subprocess.Popen calls for extra ALSA devices.""" - with ( - patch("wake_alarm._beep_loop._play_on_all_sinks") as mock, - patch("wake_alarm._startup._set_max_brightness"), - patch("wake_alarm._startup._wake_display"), - patch("wake_alarm._alarm._restore_display"), - patch("wake_alarm._startup._warn_if_no_real_sink"), - patch("wake_alarm._alarm._activate_all_outputs", return_value=None), - patch("wake_alarm._alarm._restore_audio_state"), - patch("wake_alarm._startup.turn_on_plug"), - patch("wake_alarm._alarm.turn_off_plug"), - ): - yield mock - - -@pytest.fixture -def mock_tk_module() -> Generator[MagicMock]: - """Provide explicit access to the mocked tk module.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestWakeAlarmInit: - """Tests for WakeAlarm initialization.""" - - def test_demo_mode_sets_smaller_window( - self, - mock_tk_module: MagicMock, - ) -> None: - """Demo mode still hijacks the full screen — only timers differ.""" - alarm = WakeAlarm(demo_mode=True) - assert alarm.demo_mode is True - assert alarm.dismissed is False - mock_root = mock_tk_module.Tk.return_value - # Since gatelock v0.2.0 even demo takes overrideredirect: a window - # manager rewrites a managed window's geometry, so per-output - # placement is impossible without it. attributes(fullscreen=True) - # is gone -- explicit per-output geometry replaces it. - mock_root.overrideredirect.assert_called_with(boolean=True) - assert alarm._view._views, "a surface must exist per live output" - alarm._stop_beep.set() # Stop beep thread - - def test_init_starts_sink_reactivation( - self, - mock_tk_module: MagicMock, - ) -> None: - """The retry runs, and stops when the alarm does. - - Without this the HDMI sink stays absent for the whole alarm: its card - profile is ``off`` at wake time, and only a fresh set-card-profile can - bring the sink into existence -- re-enumerating sinks never finds it. - The conftest stub is replaced here so the wiring itself is asserted. - """ - assert mock_tk_module is not None - with patch("wake_alarm._alarm.start_alarm_reactivation") as start: - alarm = WakeAlarm(demo_mode=True) - start.assert_called_once_with(alarm._stop_beep) - # The same event the beep loop watches, so dismissal ends both. - alarm._stop_beep.set() - assert start.call_args.args[0].is_set() - - def test_init_takes_the_power_key_inhibitor( - self, - mock_tk_module: MagicMock, - ) -> None: - """A short power-button press must not end the alarm in one tap.""" - assert mock_tk_module is not None - with patch("wake_alarm._alarm.take_power_key_inhibitor") as take: - alarm = WakeAlarm(demo_mode=True) - take.assert_called_once_with() - assert alarm._hardware.inhibitor is take.return_value - alarm._stop_beep.set() - - def test_init_opens_a_session_record( - self, - mock_tk_module: MagicMock, - ) -> None: - """Written while ringing, so a power cut leaves a trace to re-fire on.""" - assert mock_tk_module is not None - with patch("wake_alarm._alarm.start_session") as start: - alarm = WakeAlarm(demo_mode=True) - start.assert_called_once() - # The correlation id joins this record to the JSONL log with no - # handshake -- both sides derive it from the same alarm instant. - assert start.call_args.args[0].startswith("wake-") - alarm._stop_beep.set() - - def test_production_mode_fullscreen( - self, - mock_tk_module: MagicMock, - ) -> None: - """Production mode activates fullscreen.""" - alarm = WakeAlarm(demo_mode=False) - assert alarm.demo_mode is False - mock_root = mock_tk_module.Tk.return_value - mock_root.overrideredirect.assert_called_with(boolean=True) - # attributes(fullscreen=True) is deliberately gone: it is an EWMH - # request an override-redirect window is invisible to, and where - # it applies it snaps to one monitor or the whole bounding box. - assert alarm._view._views, "a surface must exist per live output" - alarm._stop_beep.set() diff --git a/wake_alarm/tests/test_alarm_part3.py b/wake_alarm/tests/test_alarm_part3.py deleted file mode 100644 index a4fb9d7..0000000 --- a/wake_alarm/tests/test_alarm_part3.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Tests for WakeAlarm — beep loop phases, run, update timer, and flash challenge.""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Generator - -from wake_alarm._alarm import ( - WakeAlarm, -) - - -def _make_mock_tk() -> MagicMock: - """Build a MagicMock that stands in for the tkinter module.""" - mock = MagicMock() - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - mock.Frame.return_value = MagicMock() - mock.Label.return_value = MagicMock() - mock.Entry.return_value = MagicMock() - mock.TclError = tk.TclError - mock.END = tk.END - return mock - - -@pytest.fixture(autouse=True) -def _block_real_tk() -> Generator[MagicMock]: - """Prevent any real Tk windows in tests.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -@pytest.fixture(autouse=True) -def _block_extra_devices() -> Generator[MagicMock]: - """Prevent real subprocess calls for extra ALSA devices and hardware.""" - with ( - patch("wake_alarm._beep_loop._play_on_all_sinks") as mock, - patch("wake_alarm._startup._set_max_brightness"), - patch("wake_alarm._startup._wake_display"), - patch("wake_alarm._alarm._restore_display"), - patch("wake_alarm._startup._warn_if_no_real_sink"), - patch("wake_alarm._alarm._activate_all_outputs", return_value=None), - patch("wake_alarm._alarm._restore_audio_state"), - patch("wake_alarm._startup.turn_on_plug"), - patch("wake_alarm._alarm.turn_off_plug"), - ): - yield mock - - -@pytest.fixture -def mock_tk_module() -> Generator[MagicMock]: - """Provide explicit access to the mocked tk module.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -class TestRunMethod: - """Tests for the run() method.""" - - def test_run_delegates_to_lock( - self, - mock_tk_module: MagicMock, - ) -> None: - """run() hands off to the owned LockWindow. - - Asserts delegation rather than calling the real LockWindow.run(), - which installs real SIGTERM/SIGINT handlers in the test process. - """ - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - with patch.object(alarm._lock, "run") as mock_run: - alarm.run() - mock_run.assert_called_once_with() - alarm._stop_beep.set() - - -class TestUpdateTimerActive: - """Tests for timer update when alarm is active.""" - - def test_update_timer_shows_skip_window( - self, - mock_tk_module: MagicMock, - ) -> None: - """While the skip is earnable, the timer shows the skip-window count.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._update_timer() - text = alarm._view._views[0].labels.timer.configure.call_args[1]["text"] - assert text.startswith("Skip window:") - alarm._stop_beep.set() - - def test_update_timer_shows_prompt_after_window( - self, - mock_tk_module: MagicMock, - ) -> None: - """After the window the timer shows the silence prompt and keeps going.""" - import time as time_mod - - alarm = WakeAlarm(demo_mode=True) - # Far in the past so remaining == 0 -> the else branch. - alarm._progress.started_at = time_mod.monotonic() - 60 * 60 - alarm.root.after.reset_mock() - alarm._update_timer() - text = alarm._view._views[0].labels.timer.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._view._views[0].labels.timer.configure.reset_mock() - alarm._update_timer() - alarm._view._views[0].labels.timer.configure.assert_not_called() - alarm._stop_beep.set() - - -class TestDismissWithoutSkip: - """Tests for alarm dismiss without earning skip.""" - - def test_dismiss_without_skip_shows_no_skip_message( - self, - mock_tk_module: MagicMock, - ) -> None: - """Dismissing with earned_skip=False shows appropriate message.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - mock_widget = MagicMock() - alarm._view._views[0].container.winfo_children.return_value = [mock_widget] - - with patch( - "wake_alarm._alarm.save_wake_state", - ) as mock_save: - alarm._dismiss_alarm(earned_skip=False) - - assert alarm.dismissed is True - mock_save.assert_called_once() - assert mock_save.call_args[1]["skip_workout"] is False - mock_widget.destroy.assert_called_once() - alarm._stop_beep.set() - - -class TestSkipWindowExpiredMessage: - """Tests for the on-screen message when the skip window expires.""" - - def test_expired_updates_status_label( - self, - mock_tk_module: MagicMock, - ) -> None: - """Expiry updates the status label instead of closing the alarm.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - - alarm._on_skip_window_expired() - - alarm._view._views[0].labels.status.configure.assert_called_with( - text="No workout skip today.", - ) - alarm._stop_beep.set() diff --git a/wake_alarm/tests/test_alarm_part4.py b/wake_alarm/tests/test_alarm_part4.py deleted file mode 100644 index c9f60c7..0000000 --- a/wake_alarm/tests/test_alarm_part4.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Tests for WakeAlarm's gatelock hooks: on_focus_ready, on_callback_error, on_close.""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Generator - -from wake_alarm._alarm import WakeAlarm -from wake_alarm._alarm_view import _EntryProxy -from wake_alarm._sinks import _AudioState - -# --------------------------------------------------------------------------- -# Helpers (duplicated from part 1 so this file is self-contained) -# --------------------------------------------------------------------------- - - -def _make_mock_tk() -> MagicMock: - """Build a MagicMock that stands in for the tkinter module.""" - mock = MagicMock() - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - # Distinct frames and buttons per call: a shared return_value would make - # "every monitor got one" indistinguishable from "one monitor got two". - mock.Frame.side_effect = lambda *_args, **_kwargs: MagicMock() - mock.Button.side_effect = lambda *_args, **_kwargs: MagicMock() - mock.Label.return_value = MagicMock() - mock.Entry.return_value = MagicMock() - mock.TclError = tk.TclError - mock.END = tk.END - return mock - - -@pytest.fixture(autouse=True) -def _block_real_tk() -> Generator[MagicMock]: - """Prevent any real Tk windows in tests.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -@pytest.fixture(autouse=True) -def _block_extra_devices() -> Generator[MagicMock]: - """Prevent real subprocess.Popen calls for extra ALSA devices.""" - with ( - patch("wake_alarm._beep_loop._play_on_all_sinks") as mock, - patch("wake_alarm._startup._set_max_brightness"), - patch("wake_alarm._startup._wake_display"), - patch("wake_alarm._alarm._restore_display"), - patch("wake_alarm._startup._warn_if_no_real_sink"), - patch("wake_alarm._alarm._activate_all_outputs", return_value=None), - patch("wake_alarm._alarm._restore_audio_state"), - patch("wake_alarm._startup.turn_on_plug"), - patch("wake_alarm._alarm.turn_off_plug"), - ): - yield mock - - -@pytest.fixture -def mock_tk_module() -> Generator[MagicMock]: - """Provide explicit access to the mocked tk module.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -class TestGatelockHooks: - """Tests for the LockWindowHooks callbacks (on_focus_ready/on_callback_error).""" - - def test_on_focus_ready_focuses_entry( - self, - mock_tk_module: MagicMock, - ) -> None: - """on_focus_ready forces focus onto the dismiss-code entry.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._view._views[0].entry.focus_force.reset_mock() - alarm.on_focus_ready(alarm._view._views[0]) - alarm._view._views[0].entry.focus_force.assert_called_once() - alarm._stop_beep.set() - - def test_on_callback_error_surfaces_and_refocuses( - self, - mock_tk_module: MagicMock, - ) -> None: - """on_callback_error shows a message and refocuses the entry.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._view._views[0].entry.focus_force.reset_mock() - alarm.on_callback_error() - alarm._view._views[0].labels.status.configure.assert_called_with( - text="Something went wrong — try again.", - ) - alarm._view._views[0].entry.focus_force.assert_called_once() - alarm._stop_beep.set() - - -class TestClose: - """Tests for the alarm's gatelock close path (LockWindow.close/on_close).""" - - def test_lock_close_stops_beep_and_destroys( - self, - mock_tk_module: MagicMock, - ) -> None: - """LockWindow.close() runs on_close (stop event) and destroys root.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._lock.close() - assert alarm._stop_beep.is_set() - alarm.root.destroy.assert_called() - - def test_on_close_restores_audio( - self, - mock_tk_module: MagicMock, - ) -> None: - """on_close restores the audio state captured at activation.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - state = _AudioState( - default_sink="jbl_sink", - card_profiles={}, - sink_volumes={}, - sink_mutes={}, - ) - alarm._hardware.audio_restore = state - with patch( - "wake_alarm._alarm._restore_audio_state", - ) as mock_restore: - alarm.on_close() - mock_restore.assert_called_once_with(state) - alarm._stop_beep.set() - - def test_on_close_releases_the_inhibitor_and_closes_the_session( - self, - mock_tk_module: MagicMock, - ) -> None: - """Both are released here, not in _dismiss_alarm. - - on_close() is the one path that runs on *every* exit -- dismissal, the - escape hatch and SIGTERM alike -- so an alarm killed by systemd still - gives the power button back and still marks its session finished. - """ - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - sentinel = object() - alarm._hardware.inhibitor = sentinel - with ( - patch("wake_alarm._alarm.release_power_key_inhibitor") as release, - patch("wake_alarm._alarm.finish_session") as finish, - ): - alarm.on_close() - release.assert_called_once_with(sentinel) - finish.assert_called_once_with() - alarm._stop_beep.set() - - -class TestMultiMonitorSurfaces: - """Per-output behaviour added in the gatelock v0.2.0 migration.""" - - def test_on_focus_ready_with_no_live_output( - self, mock_tk_module: MagicMock - ) -> None: - """A dark machine arms the alarm with nothing to focus.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._view._views[0].entry.focus_force.reset_mock() - - alarm.on_focus_ready(None) - - alarm._view._views[0].entry.focus_force.assert_not_called() - alarm._stop_beep.set() - - def test_focus_force_skips_destroyed_entries( - self, mock_tk_module: MagicMock - ) -> None: - """A surface whose window already died is stepped over.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - dead, alive = MagicMock(), MagicMock() - dead.focus_force.side_effect = tk.TclError("gone") - proxy = _EntryProxy([dead, alive], MagicMock()) - - proxy.focus_force() - - alive.focus_force.assert_called_once_with() - alarm._stop_beep.set() - - def test_title_label_fans_out(self, mock_tk_module: MagicMock) -> None: - """Every named label proxy reaches its per-monitor copies.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - - alarm._view.title_label.configure(text="hi") - - alarm._view._views[0].labels.title.configure.assert_called_with(text="hi") - alarm._stop_beep.set() - - def test_teardown_forgets_a_dark_output(self, mock_tk_module: MagicMock) -> None: - """An output going dark drops just its own view.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - surface = alarm._view._views[0] - - alarm.teardown_surface(surface) - - assert alarm._view._views == [] - alarm._stop_beep.set() - - def test_focus_force_when_every_entry_is_gone( - self, mock_tk_module: MagicMock - ) -> None: - """All surfaces destroyed: nothing to focus, and no exception.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - dead = MagicMock() - dead.focus_force.side_effect = tk.TclError("gone") - - _EntryProxy([dead], MagicMock()).focus_force() # must not raise - - alarm._stop_beep.set() diff --git a/wake_alarm/tests/test_alarm_playback.py b/wake_alarm/tests/test_alarm_playback.py deleted file mode 100644 index 2cd3f02..0000000 --- a/wake_alarm/tests/test_alarm_playback.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Tests for per-sink alarm playback (_play_on_all_sinks and helpers).""" - -from __future__ import annotations - -import subprocess -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from wake_alarm._audio import _play_on_all_sinks, _play_wav_on_sink, _reap_players -from wake_alarm._sinks import _ensure_sink_audible - -if TYPE_CHECKING: - from pathlib import Path - - -class TestPlayOnAllSinks: - """Tests for _play_on_all_sinks.""" - - def test_noop_when_pactl_missing(self) -> None: - """No pactl → returns without looking up paplay or spawning Popen.""" - with ( - patch("wake_alarm._audio._pactl_path", return_value=None), - patch("wake_alarm._audio.shutil.which") as mock_which, - patch("wake_alarm._audio.subprocess.Popen") as mock_popen, - ): - _play_on_all_sinks(1000) - mock_which.assert_not_called() - mock_popen.assert_not_called() - - def test_warns_when_paplay_missing(self) -> None: - """pactl present but paplay absent → warning, no Popen.""" - with ( - patch("wake_alarm._audio._pactl_path", return_value="/usr/bin/pactl"), - patch("wake_alarm._audio.shutil.which", return_value=None), - patch("wake_alarm._audio.subprocess.Popen") as mock_popen, - ): - _play_on_all_sinks(1000) - mock_popen.assert_not_called() - - def test_returns_when_wav_generation_fails(self) -> None: - """OSError generating the tone WAV → returns, no Popen.""" - with ( - patch("wake_alarm._audio._pactl_path", return_value="/usr/bin/pactl"), - patch("wake_alarm._audio.shutil.which", return_value="/usr/bin/paplay"), - patch( - "wake_alarm._audio._ensure_tone_wav", - side_effect=OSError("no tempdir"), - ), - patch("wake_alarm._audio.subprocess.Popen") as mock_popen, - ): - _play_on_all_sinks(1000) - mock_popen.assert_not_called() - - def test_plays_and_maxes_each_sink(self, tmp_path: Path) -> None: - """Each real sink is made audible, then played on via paplay --device.""" - wav = tmp_path / "tone.wav" - with ( - patch("wake_alarm._audio._pactl_path", return_value="/usr/bin/pactl"), - patch("wake_alarm._audio.shutil.which", return_value="/usr/bin/paplay"), - patch("wake_alarm._audio._ensure_tone_wav", return_value=wav), - patch( - "wake_alarm._audio._list_real_sinks", - return_value=["sink_a", "sink_b"], - ), - patch("wake_alarm._audio._ensure_sink_audible") as mock_audible, - patch("wake_alarm._audio.subprocess.Popen") as mock_popen, - ): - _play_on_all_sinks(1000) - assert mock_audible.call_count == 2 - devices = [call.args[0][1] for call in mock_popen.call_args_list] - assert devices == ["--device=sink_a", "--device=sink_b"] - - def test_skips_sink_whose_launch_failed(self) -> None: - """A sink whose paplay fails to launch (None) is not reaped.""" - with ( - patch("wake_alarm._audio._pactl_path", return_value="/usr/bin/pactl"), - patch("wake_alarm._audio.shutil.which", return_value="/usr/bin/paplay"), - patch("wake_alarm._audio._ensure_tone_wav", return_value=MagicMock()), - patch("wake_alarm._audio._list_real_sinks", return_value=["sink_a"]), - patch("wake_alarm._audio._ensure_sink_audible"), - patch("wake_alarm._audio._play_wav_on_sink", return_value=None), - patch("wake_alarm._audio._reap_players") as mock_reap, - ): - _play_on_all_sinks(1000) - mock_reap.assert_called_once_with([]) - - -class TestReapPlayers: - """Tests for _reap_players.""" - - def test_waits_for_each(self) -> None: - """Each launched paplay is waited on (reaped).""" - procs = [MagicMock(), MagicMock()] - _reap_players(procs) - for proc in procs: - proc.wait.assert_called_once() - - def test_kills_hung_player(self) -> None: - """A paplay that exceeds the timeout is killed and then reaped.""" - proc = MagicMock() - proc.wait.side_effect = [subprocess.TimeoutExpired("paplay", 6), None] - _reap_players([proc]) - proc.kill.assert_called_once() - assert proc.wait.call_count == 2 - - -class TestEnsureSinkAudible: - """Tests for _ensure_sink_audible.""" - - def test_unmutes_and_maxes(self) -> None: - """Runs set-sink-mute 0 then set-sink-volume 100% for the sink.""" - with patch("wake_alarm._sinks.subprocess.run") as mock_run: - _ensure_sink_audible("/usr/bin/pactl", "sink_a") - cmds = [call.args[0] for call in mock_run.call_args_list] - assert ["/usr/bin/pactl", "set-sink-mute", "sink_a", "0"] in cmds - assert ["/usr/bin/pactl", "set-sink-volume", "sink_a", "100%"] in cmds - - -class TestPlayWavOnSink: - """Tests for _play_wav_on_sink.""" - - def test_spawns_paplay_with_device(self, tmp_path: Path) -> None: - """Popen is called with paplay --device= and the wav path.""" - with patch("wake_alarm._audio.subprocess.Popen") as mock_popen: - _play_wav_on_sink("/usr/bin/paplay", "sink_a", tmp_path / "tone.wav") - args = mock_popen.call_args.args[0] - assert args[0] == "/usr/bin/paplay" - assert args[1] == "--device=sink_a" - - def test_ignores_oserror(self, tmp_path: Path) -> None: - """OSError from Popen is swallowed (must not raise).""" - with patch( - "wake_alarm._audio.subprocess.Popen", - side_effect=OSError("device busy"), - ): - _play_wav_on_sink("/usr/bin/paplay", "sink_a", tmp_path / "tone.wav") diff --git a/wake_alarm/tests/test_alarm_session.py b/wake_alarm/tests/test_alarm_session.py deleted file mode 100644 index 415f1e0..0000000 --- a/wake_alarm/tests/test_alarm_session.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Tests for the fire-time session record that survives a hard power-off.""" - -from __future__ import annotations - -from datetime import datetime -import json -from typing import TYPE_CHECKING - -import pytest - -from wake_alarm import _alarm_session -from wake_alarm._alarm_session import ( - STATE_FINISHED, - STATE_RINGING, - finish_session, - load_session, - should_refire, - start_session, -) - -if TYPE_CHECKING: - from pathlib import Path - - -@pytest.fixture(autouse=True) -def session_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Point the record at a temporary file, never the real one.""" - path = tmp_path / "alarm_session.json" - monkeypatch.setattr(_alarm_session, "ALARM_SESSION_FILE", path) - return path - - -class TestStartSession: - """Opening a record the moment the alarm starts ringing.""" - - def test_writes_a_signed_ringing_record(self, session_file: Path) -> None: - """The record exists before anything can go wrong.""" - assert start_session("wake-20260827-0700") is True - entry = json.loads(session_file.read_text()) - assert entry["state"] == STATE_RINGING - assert entry["correlation"] == "wake-20260827-0700" - assert "hmac" in entry - # An aware timestamp: freshness is measured as elapsed time, so the - # offset has to be carried rather than assumed. - assert datetime.fromisoformat(str(entry["fired_at"])).tzinfo is not None - - def test_creates_the_parent_directory( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """A fresh install has no ~/.config/wake_alarm yet.""" - monkeypatch.setattr( - _alarm_session, "ALARM_SESSION_FILE", tmp_path / "new" / "s.json" - ) - assert start_session("wake-20260827-0700") is True - - def test_unwritable_path_is_survivable( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, - ) -> None: - """A failed write must never stop the alarm from ringing.""" - blocker = tmp_path / "blocker" - blocker.write_text("not a directory") - monkeypatch.setattr(_alarm_session, "ALARM_SESSION_FILE", blocker / "s.json") - with caplog.at_level("WARNING"): - assert start_session("wake-20260827-0700") is False - assert "Failed to write" in caplog.text - - def test_saves_unsigned_when_no_hmac_key( - self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture - ) -> None: - """An unsigned record still beats no record at all.""" - monkeypatch.setattr(_alarm_session, "compute_entry_hmac", lambda _entry: None) - with caplog.at_level("WARNING"): - assert start_session("wake-20260827-0700") is True - assert "unsigned alarm session" in caplog.text - - -class TestFinishSession: - """Closing the record however the alarm actually ended.""" - - def test_marks_the_record_finished(self, session_file: Path) -> None: - """Dismissal, escape hatch and SIGTERM all land here.""" - start_session("wake-20260827-0700") - assert finish_session() is True - entry = json.loads(session_file.read_text()) - assert entry["state"] == STATE_FINISHED - # fired_at is preserved: it is the audit trail of when it rang. - assert entry["fired_at"] is not None - assert entry["correlation"] == "wake-20260827-0700" - - def test_no_record_to_finish(self) -> None: - """on_close() runs even when no session was ever opened.""" - assert finish_session() is False - - -class TestLoadSession: - """Reading the record back, rejecting anything untrustworthy.""" - - def test_absent_file(self) -> None: - """No record is not an error.""" - assert load_session() is None - - def test_unreadable_json( - self, session_file: Path, caplog: pytest.LogCaptureFixture - ) -> None: - """A truncated write is treated as no record.""" - session_file.write_text("{not json") - with caplog.at_level("WARNING"): - assert load_session() is None - assert "Cannot read" in caplog.text - - def test_non_dict_payload(self, session_file: Path) -> None: - """A JSON list is not a record.""" - session_file.write_text("[1, 2, 3]") - assert load_session() is None - - def test_an_unsigned_record_is_honoured( - self, - session_file: Path, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, - ) -> None: - """No HMAC key must not silently disable the power-off defence. - - ``verify_entry_hmac`` returns False both for a forgery *and* for a - machine with no key at all. Treating them alike meant that on such a - machine every session record was unreadable and the re-fire simply did - not happen -- found by running the vmbox harness, where /etc/gatelock - is absent. - """ - monkeypatch.setattr(_alarm_session, "compute_entry_hmac", lambda _e: None) - start_session("wake-20260827-0700") - assert "hmac" not in json.loads(session_file.read_text()) - with caplog.at_level("WARNING"): - assert should_refire() is True - assert "unsigned" in caplog.text - - def test_tampered_record_is_rejected( - self, session_file: Path, caplog: pytest.LogCaptureFixture - ) -> None: - """Hand-editing state must not be able to fake -- or force -- a ring.""" - start_session("wake-20260827-0700") - entry = json.loads(session_file.read_text()) - entry["state"] = STATE_FINISHED - session_file.write_text(json.dumps(entry)) - with caplog.at_level("WARNING"): - assert load_session() is None - assert "HMAC verification failed" in caplog.text diff --git a/wake_alarm/tests/test_alarm_session_refire.py b/wake_alarm/tests/test_alarm_session_refire.py deleted file mode 100644 index ae7884c..0000000 --- a/wake_alarm/tests/test_alarm_session_refire.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Tests for the re-fire decision driven by the session record. - -Split from ``test_alarm_session.py`` for the 250-line cap. The header is copied -whole: without the autouse ``session_file`` fixture these tests would read and -write the *production* record, which the next real trigger tick would act on. -""" - -from __future__ import annotations - -from datetime import UTC, datetime, timedelta -import json -from typing import TYPE_CHECKING - -import pytest - -from wake_alarm import _alarm_session -from wake_alarm._alarm_session import ( - STATE_RINGING, - finish_session, - should_refire, - start_session, - unfinished_session_age, -) - -if TYPE_CHECKING: - from pathlib import Path - - -@pytest.fixture(autouse=True) -def session_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Point the record at a temporary file, never the real one.""" - path = tmp_path / "alarm_session.json" - monkeypatch.setattr(_alarm_session, "ALARM_SESSION_FILE", path) - return path - - -class TestShouldRefire: - """The decision _trigger acts on.""" - - def test_no_record_does_not_refire(self) -> None: - """The ordinary case: nothing was cut short.""" - assert should_refire() is False - - def test_ringing_record_refires(self) -> None: - """The whole point: a power-off mid-alarm rings again.""" - start_session("wake-20260827-0700") - assert should_refire() is True - - def test_finished_record_does_not_refire(self) -> None: - """A dismissed alarm stays dismissed.""" - start_session("wake-20260827-0700") - finish_session() - assert should_refire() is False - - def test_stale_record_does_not_refire( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Bounded on purpose. - - An alarm that died before dismissal leaves a record nothing will ever - clear; unbounded, this would restart the alarm every minute forever. - """ - start_session("wake-20260827-0700") - much_later = datetime.now(tz=UTC).astimezone() + timedelta(days=1) - with caplog.at_level("INFO"): - assert should_refire(now=much_later) is False - assert "Ignoring an unfinished alarm session" in caplog.text - - def test_a_backward_clock_jump_still_refires( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Fails *closed*, which is the whole point of the record. - - NTP correcting after a resume moves the clock backwards, leaving the - record stamped in the future. Discarding it there would mean: alarm - fires on a fast clock, clock is corrected, and the power-off escape - quietly works again -- on a machine that hibernates nightly, i.e. the - exact scenario this record exists to close. - """ - start_session("wake-20260827-0700") - corrected = datetime.now(tz=UTC).astimezone() - timedelta(minutes=5) - with caplog.at_level("INFO"): - assert should_refire(now=corrected) is True - assert "in the future" in caplog.text - - def test_a_wildly_future_record_is_ignored( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Bounded, so a broken RTC cannot create an endless re-fire.""" - start_session("wake-20260827-0700") - way_earlier = datetime.now(tz=UTC).astimezone() - timedelta(hours=6) - with caplog.at_level("WARNING"): - assert should_refire(now=way_earlier) is False - assert "beyond the" in caplog.text - - def test_missing_timestamp_is_ignored( - self, - session_file: Path, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, - ) -> None: - """A record with no usable fired_at cannot be aged, so it is dropped.""" - monkeypatch.setattr(_alarm_session, "verify_entry_hmac", lambda _e: True) - session_file.write_text(json.dumps({"state": STATE_RINGING})) - with caplog.at_level("WARNING"): - assert unfinished_session_age() is None - assert "no usable fired_at" in caplog.text - - @pytest.mark.parametrize( - "fired_at", - [ - "not-a-timestamp", - # Naive: without an offset the elapsed-time comparison is - # meaningless, which is exactly the ambiguity this record avoids. - "2026-08-27T07:00:00", - ], - ) - def test_unusable_timestamps_are_ignored( - self, session_file: Path, monkeypatch: pytest.MonkeyPatch, fired_at: str - ) -> None: - """Neither an unparsable nor a naive timestamp can drive a re-fire.""" - monkeypatch.setattr(_alarm_session, "verify_entry_hmac", lambda _e: True) - session_file.write_text( - json.dumps({"state": STATE_RINGING, "fired_at": fired_at}) - ) - assert should_refire() is False - - def test_age_is_reported_for_a_live_session(self) -> None: - """The age helper is what makes the bound observable.""" - start_session("wake-20260827-0700") - later = datetime.now(tz=UTC).astimezone() + timedelta(seconds=90) - age = unfinished_session_age(now=later) - assert age is not None - assert 89 <= age <= 92 diff --git a/wake_alarm/tests/test_alarm_sinks.py b/wake_alarm/tests/test_alarm_sinks.py deleted file mode 100644 index 2a7cc06..0000000 --- a/wake_alarm/tests/test_alarm_sinks.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Tests for pactl sink/card enumeration, activation, and restore.""" - -from __future__ import annotations - -import subprocess -from unittest.mock import MagicMock, patch - -from wake_alarm._gating import parse_args as _parse_args -from wake_alarm._sink_parsing import _parse_profile_line -from wake_alarm._sinks import ( - _capture_audio_state, - _current_default_sink, - _list_cards, - _list_real_sinks, - _pactl_text, - _parse_cards, - _parse_sinks, -) - -# A representative `pactl list cards` block covering every parse branch: -# an HDMI card (off, with an available stereo output profile), an input-only -# card (no output profile), a card block with no Name line (must be skipped). -_CARDS_TEXT = """Card #54 -\tName: alsa_card.hdmi -\tDriver: module-alsa-card.c -\tProfiles: -\t\toff: Off (sinks: 0, sources: 0, priority: 0, available: yes) -\t\toutput:hdmi-stereo: Stereo (sinks: 1, priority: 5900, available: yes) -\t\toutput:hdmi-surround: Surround (sinks: 1, priority: 600, available: no) -\t\tbroken (available: yes) -\tActive Profile: off -Card #55 -\tName: alsa_card.mic -\tProfiles: -\t\toff: Off (sinks: 0, sources: 0, priority: 0, available: yes) -\tActive Profile: off -Card #56 -\tProfiles: -\t\toutput:noname: NoName (available: yes) -\tActive Profile: off -""" - -# A `pactl list sinks` block: a normal sink, plus one whose Volume line has -# no integer (to cover the "no digit" branch), plus a leading non-Name line. -_SINKS_TEXT = """Sink #62 -\tState: SUSPENDED -\tName: alsa_output.analog -\tMute: no -\tVolume: front-left: 65536 / 100% -\tDescription: Built-in Analog -Sink #63 -\tName: alsa_output.weird -\tMute: yes -\tVolume: n/a -""" - - -class TestPactlText: - """Tests for _pactl_text.""" - - def test_returns_decoded_stdout(self) -> None: - """Success → decoded stdout string.""" - proc = MagicMock(stdout=b"hello\n") - with patch("wake_alarm._sinks.subprocess.run", return_value=proc): - assert _pactl_text("/usr/bin/pactl", "info") == "hello\n" - - def test_returns_none_on_error(self) -> None: - """OSError/TimeoutExpired → None, no raise.""" - with patch( - "wake_alarm._sinks.subprocess.run", - side_effect=subprocess.TimeoutExpired("pactl", 5), - ): - assert _pactl_text("/usr/bin/pactl", "info") is None - - -class TestPactlPath: - """Tests for _pactl_path.""" - - def test_returns_which_result(self) -> None: - """Delegates to shutil.which('pactl').""" - from wake_alarm._sinks import _pactl_path - - with patch( - "wake_alarm._sinks.shutil.which", - return_value="/usr/bin/pactl", - ): - assert _pactl_path() == "/usr/bin/pactl" - - -class TestListRealSinks: - """Tests for _list_real_sinks.""" - - def test_filters_auto_null(self) -> None: - """auto_null is dropped; real sink names are returned.""" - text = ( - "62\talsa_output.analog\tPipeWire\t-\t-\n4319\tauto_null\tPipeWire\t-\t-\n" - ) - with patch("wake_alarm._sinks._pactl_text", return_value=text): - assert _list_real_sinks("/usr/bin/pactl") == ["alsa_output.analog"] - - def test_returns_empty_on_none(self) -> None: - """pactl failure (None text) → empty list.""" - with patch("wake_alarm._sinks._pactl_text", return_value=None): - assert _list_real_sinks("/usr/bin/pactl") == [] - - -class TestListCards: - """Tests for _list_cards.""" - - def test_parses_names(self) -> None: - """Returns the card name column of `list short cards`.""" - text = "54\talsa_card.hdmi\talsa\n55\talsa_card.mic\talsa\n" - with patch("wake_alarm._sinks._pactl_text", return_value=text): - assert _list_cards("/usr/bin/pactl") == [ - "alsa_card.hdmi", - "alsa_card.mic", - ] - - def test_returns_empty_on_none(self) -> None: - """pactl failure (None text) → empty list.""" - with patch("wake_alarm._sinks._pactl_text", return_value=None): - assert _list_cards("/usr/bin/pactl") == [] - - -class TestParseProfileLine: - """Tests for _parse_profile_line.""" - - def test_non_output_returns_none(self) -> None: - """A non-output profile line → None.""" - assert _parse_profile_line("off: Off (available: yes)") is None - - def test_unavailable_returns_none(self) -> None: - """An output profile marked available: no → None.""" - assert _parse_profile_line("output:x: X (available: no)") is None - - def test_missing_colon_space_returns_none(self) -> None: - """An output line with no ': ' separator → None.""" - assert _parse_profile_line("output:x") is None - - def test_defaults_priority_zero(self) -> None: - """An available output with no priority field → priority 0.""" - assert _parse_profile_line("output:x: X (available: yes)") == ("output:x", 0) - - def test_parses_priority(self) -> None: - """Priority is parsed from the line.""" - assert _parse_profile_line( - "output:hdmi: HDMI (priority: 5900, available: yes)", - ) == ("output:hdmi", 5900) - - -class TestParseCards: - """Tests for _parse_cards.""" - - def test_parses_and_skips_edge_cases(self) -> None: - """Stereo preferred, input-only card empty, unnamed card skipped.""" - cards = _parse_cards(_CARDS_TEXT) - assert set(cards) == {"alsa_card.hdmi", "alsa_card.mic"} - assert cards["alsa_card.hdmi"].active_profile == "off" - assert cards["alsa_card.hdmi"].output_profiles == ["output:hdmi-stereo"] - assert cards["alsa_card.mic"].output_profiles == [] - - def test_ignores_lines_before_first_card(self) -> None: - """Leading noise before the first `Card #` is ignored.""" - assert _parse_cards("junk\nmore junk\n") == {} - - -class TestParseSinks: - """Tests for _parse_sinks.""" - - def test_parses_volume_and_mute(self) -> None: - """Captures first raw volume and mute state per sink.""" - volumes, mutes = _parse_sinks(_SINKS_TEXT) - assert volumes["alsa_output.analog"] == "65536" - assert mutes == {"alsa_output.analog": False, "alsa_output.weird": True} - - def test_volume_without_digit_is_skipped(self) -> None: - """A Volume line with no integer is not recorded.""" - volumes, _ = _parse_sinks(_SINKS_TEXT) - assert "alsa_output.weird" not in volumes - - -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("wake_alarm._sinks.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("wake_alarm._sinks.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( - "wake_alarm._sinks.subprocess.run", - side_effect=subprocess.TimeoutExpired("pactl", 3), - ): - assert _current_default_sink("/usr/bin/pactl") is None - - -class TestCaptureAudioState: - """Tests for _capture_audio_state.""" - - def test_captures_profiles_volumes_mutes_default(self) -> None: - """Combines card profiles, sink volumes/mutes, and the default sink.""" - with ( - patch( - "wake_alarm._sinks._pactl_text", - side_effect=[_CARDS_TEXT, _SINKS_TEXT], - ), - patch( - "wake_alarm._sinks._current_default_sink", - return_value="alsa_output.analog", - ), - ): - state = _capture_audio_state("/usr/bin/pactl") - assert state.default_sink == "alsa_output.analog" - assert state.card_profiles["alsa_card.hdmi"] == "off" - assert state.sink_volumes["alsa_output.analog"] == "65536" - assert state.sink_mutes["alsa_output.analog"] is False - - -class TestParseArgs: - """Tests for _parse_args.""" - - def test_default_flags_are_false(self) -> None: - """No CLI args means every flag is False.""" - ns = _parse_args([]) - assert ns.demo is False - assert ns.trigger_now is False - assert ns.production is False - - def test_flags_parse(self) -> None: - """Each flag flips to True when passed.""" - ns = _parse_args(["--production", "--demo", "--trigger-now"]) - assert ns.production is True - assert ns.demo is True - assert ns.trigger_now is True diff --git a/wake_alarm/tests/test_alarm_timers_flash.py b/wake_alarm/tests/test_alarm_timers_flash.py deleted file mode 100644 index 5a902ec..0000000 --- a/wake_alarm/tests/test_alarm_timers_flash.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Tests for _alarm.py — the timers and screen flash driven by root.after. - -Split from ``test_alarm_part2.py`` for the 250-line cap. The header -- -``_make_mock_tk``, ``_block_real_tk``, ``_block_extra_devices`` and -``mock_tk_module`` -- is copied whole rather than trimmed: those fixtures sit -*after* the imports, so a header-only copy silently drops them and every test -in the file errors. -""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Generator - -from wake_alarm._alarm import ( - WakeAlarm, -) - -# --------------------------------------------------------------------------- -# Helpers (duplicated from part 1 so this file is self-contained) -# --------------------------------------------------------------------------- - - -def _make_mock_tk() -> MagicMock: - """Build a MagicMock that stands in for the tkinter module.""" - mock = MagicMock() - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - mock.Frame.return_value = MagicMock() - mock.Label.return_value = MagicMock() - mock.Entry.return_value = MagicMock() - mock.TclError = tk.TclError - mock.END = tk.END - return mock - - -@pytest.fixture(autouse=True) -def _block_real_tk() -> Generator[MagicMock]: - """Prevent any real Tk windows in tests.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -@pytest.fixture(autouse=True) -def _block_extra_devices() -> Generator[MagicMock]: - """Prevent real subprocess.Popen calls for extra ALSA devices.""" - with ( - patch("wake_alarm._beep_loop._play_on_all_sinks") as mock, - patch("wake_alarm._startup._set_max_brightness"), - patch("wake_alarm._startup._wake_display"), - patch("wake_alarm._alarm._restore_display"), - patch("wake_alarm._startup._warn_if_no_real_sink"), - patch("wake_alarm._alarm._activate_all_outputs", return_value=None), - patch("wake_alarm._alarm._restore_audio_state"), - patch("wake_alarm._startup.turn_on_plug"), - patch("wake_alarm._alarm.turn_off_plug"), - ): - yield mock - - -@pytest.fixture -def mock_tk_module() -> Generator[MagicMock]: - """Provide explicit access to the mocked tk module.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch( - "wake_alarm._alarm.GateRoot", - return_value=mock.Tk.return_value, - ), - ): - yield mock - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestCodeRefreshAndTimer: - """Tests for code refresh and timer update methods.""" - - def test_code_refresh_changes_challenge( - self, - mock_tk_module: MagicMock, - ) -> None: - """Code refresh generates a new challenge each call.""" - alarm = WakeAlarm(demo_mode=True) - displays = set() - for _ in range(50): - alarm._schedule_code_refresh() - displays.add(alarm._progress.current_challenge.display) - assert len(displays) > 1 - alarm._stop_beep.set() - - def test_code_refresh_noop_when_not_active( - self, - mock_tk_module: MagicMock, - ) -> None: - """Code refresh is a no-op when alarm is no longer active.""" - alarm = WakeAlarm(demo_mode=True) - alarm._active = False - old_challenge = alarm._progress.current_challenge - alarm._schedule_code_refresh() - assert alarm._progress.current_challenge is old_challenge - alarm._stop_beep.set() - - def test_update_timer_noop_when_not_active( - self, - mock_tk_module: MagicMock, - ) -> None: - """Timer update is a no-op when alarm is inactive.""" - alarm = WakeAlarm(demo_mode=True) - alarm._active = False - alarm._update_timer() # Should not raise - alarm._stop_beep.set() - - -class TestScreenFlash: - """Tests for _start_screen_flash and _flash_step.""" - - def test_flash_step_shows_dark_on_flash_off( - self, - mock_tk_module: MagicMock, - ) -> None: - """When _flash_on=False, the background is set to dark colour.""" - alarm = WakeAlarm(demo_mode=True) - mock_root = mock_tk_module.Tk.return_value - mock_root.configure.reset_mock() - mock_root.after.reset_mock() - - alarm._progress.flash_on = False - alarm._flash_step() - - mock_root.configure.assert_called_once_with(bg=alarm._view.config.bg) - assert alarm._progress.flash_on is True - mock_root.after.assert_called_with(750, alarm._flash_step) - alarm._stop_beep.set() - - def test_flash_step_shows_red_on_flash_on( - self, - mock_tk_module: MagicMock, - ) -> None: - """When _flash_on=True, the background is set to red.""" - alarm = WakeAlarm(demo_mode=True) - mock_root = mock_tk_module.Tk.return_value - mock_root.configure.reset_mock() - mock_root.after.reset_mock() - - alarm._progress.flash_on = True - alarm._flash_step() - - mock_root.configure.assert_called_once_with(bg=alarm._view.config.danger) - assert alarm._progress.flash_on is False - mock_root.after.assert_called_with(750, alarm._flash_step) - alarm._stop_beep.set() - - def test_flash_step_stops_when_inactive( - self, - mock_tk_module: MagicMock, - ) -> None: - """When alarm is no longer active, _flash_step returns without scheduling.""" - alarm = WakeAlarm(demo_mode=True) - mock_root = mock_tk_module.Tk.return_value - alarm._active = False - mock_root.configure.reset_mock() - mock_root.after.reset_mock() - - alarm._flash_step() - - mock_root.configure.assert_not_called() - mock_root.after.assert_not_called() - alarm._stop_beep.set() diff --git a/wake_alarm/tests/test_beep_loop.py b/wake_alarm/tests/test_beep_loop.py deleted file mode 100644 index ae717d0..0000000 --- a/wake_alarm/tests/test_beep_loop.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Tests for the background beep loop.""" - -from __future__ import annotations - -import threading -from typing import TYPE_CHECKING -from unittest.mock import patch - -from wake_alarm._beep_loop import run_beep_loop, start_beep_thread -from wake_alarm._constants import ( - FAILSAFE_SILENCE_SECONDS, - PHASE_MEDIUM_END, - PHASE_SOFT_END, -) - -if TYPE_CHECKING: - from collections.abc import Callable - - import pytest - - -class _Clock: - """Monotonic stand-in that advances a fixed step on every read.""" - - def __init__(self, start: float, step: float) -> None: - self._now = start - self._step = step - - def __call__(self) -> float: - value = self._now - self._now += self._step - return value - - -def _run_once(elapsed: float) -> dict[str, int]: - """Run exactly one loop iteration at *elapsed* seconds and count the calls.""" - stop = threading.Event() - counts = {"soft": 0, "medium": 0, "loud": 0, "sinks": 0} - - def _count(key: str) -> Callable[..., None]: - def _inner(*_a: object, **_k: object) -> None: - counts[key] += 1 - stop.set() - - return _inner - - with ( - patch("wake_alarm._beep_loop._beep_soft", side_effect=_count("soft")), - patch("wake_alarm._beep_loop._beep_medium", side_effect=_count("medium")), - patch("wake_alarm._beep_loop._beep_loud", side_effect=_count("loud")), - patch( - "wake_alarm._beep_loop._play_on_all_sinks", - side_effect=lambda *_a: counts.__setitem__("sinks", counts["sinks"] + 1), - ), - ): - run_beep_loop(stop, 0.0, monotonic=lambda: elapsed) - return counts - - -def test_exits_immediately_when_already_stopped() -> None: - """A stop set before entry means no sound at all.""" - stop = threading.Event() - stop.set() - with patch("wake_alarm._beep_loop._play_on_all_sinks") as mock_play: - run_beep_loop(stop, 0.0, monotonic=lambda: 0.0) - mock_play.assert_not_called() - - -def test_soft_phase_uses_the_soft_beep() -> None: - """Early in the ring the gentle tone sounds.""" - assert _run_once(0.0)["soft"] == 1 - - -def test_medium_phase_uses_the_medium_beep() -> None: - """The middle band uses the medium tone.""" - midpoint = (PHASE_SOFT_END + PHASE_MEDIUM_END) / 2 * 60 - assert _run_once(midpoint)["medium"] == 1 - - -def test_loud_phase_uses_the_loud_beep() -> None: - """Past the medium boundary the alarm is at its most insistent.""" - assert _run_once(PHASE_MEDIUM_END * 60 + 1)["loud"] == 1 - - -def test_plays_on_every_sink_while_audible() -> None: - """Audio is driven to all outputs, not one chosen sink.""" - assert _run_once(0.0)["sinks"] == 1 - - -def test_failsafe_plays_no_audio() -> None: - """Past the failsafe nothing sounds -- on any output.""" - stop = threading.Event() - calls = {"n": 0} - - def _stop_after(*_a: object, **_k: object) -> None: - calls["n"] += 1 - stop.set() - - ticks = {"n": 0} - - def _wait(_timeout: float) -> bool: - # Nothing sounds during the failsafe, so no audio callback can end the - # loop; stop it from the wait instead. - ticks["n"] += 1 - if ticks["n"] >= 3: - stop.set() - return stop.is_set() - - with ( - patch("wake_alarm._beep_loop._play_on_all_sinks", side_effect=_stop_after), - patch("wake_alarm._beep_loop._beep_soft", side_effect=_stop_after), - patch("wake_alarm._beep_loop._beep_medium", side_effect=_stop_after), - patch("wake_alarm._beep_loop._beep_loud", side_effect=_stop_after), - patch.object(stop, "wait", _wait), - ): - run_beep_loop( - stop, - 0.0, - monotonic=_Clock(FAILSAFE_SILENCE_SECONDS, 1.0), - ) - assert calls["n"] == 0 - assert ticks["n"] == 3 - - -def test_failsafe_is_announced_once(caplog: pytest.LogCaptureFixture) -> None: - """Reaching the failsafe is logged loudly, and only once.""" - stop = threading.Event() - clock = _Clock(FAILSAFE_SILENCE_SECONDS, 1.0) - ticks = {"n": 0} - - def _wait(_timeout: float) -> bool: - ticks["n"] += 1 - if ticks["n"] >= 3: - stop.set() - return stop.is_set() - - with ( - caplog.at_level("WARNING"), - patch.object(stop, "wait", _wait), - ): - run_beep_loop(stop, 0.0, monotonic=clock) - assert caplog.text.count("Failsafe reached") == 1 - - -def test_start_beep_thread_is_a_daemon() -> None: - """A stuck beep thread must never keep the process alive.""" - stop = threading.Event() - stop.set() - thread = start_beep_thread(stop, 0.0) - thread.join(timeout=5) - assert thread.daemon diff --git a/wake_alarm/tests/test_brightness.py b/wake_alarm/tests/test_brightness.py deleted file mode 100644 index 388f32d..0000000 --- a/wake_alarm/tests/test_brightness.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Tests for raising every monitor to full brightness.""" - -from __future__ import annotations - -import subprocess -from unittest.mock import MagicMock, patch - -from wake_alarm._brightness import _set_max_brightness - - -class TestSetMaxBrightness: - """Tests for _set_max_brightness.""" - - def test_noop_when_xrandr_missing(self) -> None: - """No xrandr on PATH → subprocess.run never called.""" - with ( - patch("wake_alarm._audio.shutil.which", return_value=None), - patch("wake_alarm._audio.subprocess.run") as mock_run, - ): - _set_max_brightness() - mock_run.assert_not_called() - - def test_noop_on_oserror_from_query(self) -> None: - """OSError from xrandr --query is suppressed.""" - with ( - patch( - "wake_alarm._audio.shutil.which", - return_value="/usr/bin/xrandr", - ), - patch( - "wake_alarm._audio.subprocess.run", - side_effect=OSError("no display"), - ), - ): - _set_max_brightness() # must not raise - - def test_noop_on_timeout_from_query(self) -> None: - """TimeoutExpired from xrandr --query is suppressed.""" - with ( - patch( - "wake_alarm._audio.shutil.which", - return_value="/usr/bin/xrandr", - ), - patch( - "wake_alarm._audio.subprocess.run", - side_effect=subprocess.TimeoutExpired("xrandr", 5), - ), - ): - _set_max_brightness() # must not raise - - def test_sets_brightness_for_connected_displays(self) -> None: - """Connected displays each get an --output --brightness call.""" - mock_query_result = MagicMock() - mock_query_result.stdout = ( - "HDMI-0 connected 2560x1440+0+0\nDP-0 connected primary\n" - ) - call_args_list: list[list[str]] = [] - - def fake_run(args: list[str], **_kwargs: object) -> MagicMock: - call_args_list.append(args) - return mock_query_result - - with ( - patch( - "wake_alarm._audio.shutil.which", - return_value="/usr/bin/xrandr", - ), - patch("wake_alarm._audio.subprocess.run", side_effect=fake_run), - ): - _set_max_brightness() - - # First call is --query; subsequent calls set brightness for each output. - brightness_calls = [a for a in call_args_list if "--brightness" in a] - expected_brightness_calls = 2 - assert len(brightness_calls) == expected_brightness_calls - - def test_skips_disconnected_outputs(self) -> None: - """Disconnected outputs do NOT get a brightness call.""" - mock_result = MagicMock() - mock_result.stdout = "Screen 0: minimum 320\nHDMI-0 disconnected\n" - with ( - patch( - "wake_alarm._audio.shutil.which", - return_value="/usr/bin/xrandr", - ), - patch( - "wake_alarm._audio.subprocess.run", - return_value=mock_result, - ) as mock_run, - ): - _set_max_brightness() - # Only the --query call, no brightness calls. - assert mock_run.call_count == 1 - - def test_warns_when_brightness_call_fails(self) -> None: - """OSError on per-output --brightness call is logged but swallowed.""" - query_result = MagicMock() - query_result.stdout = ( - "Screen 0: minimum 320\nHDMI-0 connected primary 1920x1080\n" - ) - - def _run_side_effect(args: list[str], **_kwargs: object) -> MagicMock: - if "--query" in args: - return query_result - msg = "permission denied" - raise OSError(msg) - - with ( - patch( - "wake_alarm._audio.shutil.which", - return_value="/usr/bin/xrandr", - ), - patch( - "wake_alarm._audio.subprocess.run", - side_effect=_run_side_effect, - ), - ): - _set_max_brightness() # must not raise diff --git a/wake_alarm/tests/test_correlation.py b/wake_alarm/tests/test_correlation.py deleted file mode 100644 index f2b9fdd..0000000 --- a/wake_alarm/tests/test_correlation.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Tests for the derived correlation id.""" - -from __future__ import annotations - -from datetime import datetime -from typing import TYPE_CHECKING - -from wake_alarm import _correlation -from wake_alarm._correlation import correlation_id, correlation_id_for -from wake_alarm._sync import Alarm - -if TYPE_CHECKING: - import pytest - - -def _local(year: int, month: int, day: int, hour: int, minute: int) -> datetime: - """Build an aware local datetime, matching the idiom in test_trigger.py.""" - return datetime(year, month, day, hour, minute, tzinfo=None).astimezone() - - -def test_correlation_id_formats_local_instant() -> None: - """The id encodes the local date and the alarm's wall-clock time.""" - assert correlation_id(_local(2026, 8, 26, 7, 0)) == "wake-20260826-0700" - - -def test_correlation_id_pads_single_digits() -> None: - """Single-digit months, days, hours and minutes are zero padded.""" - assert correlation_id(_local(2026, 1, 2, 3, 4)) == "wake-20260102-0304" - - -def test_correlation_id_is_stable_for_the_same_instant() -> None: - """Two independent computations agree, which is the whole point.""" - moment = _local(2026, 12, 31, 23, 59) - assert correlation_id(moment) == correlation_id(moment) - - -def test_correlation_id_for_uses_the_date_of_now() -> None: - """The (hour, minute) form takes its date from the supplied instant.""" - assert ( - correlation_id_for(7, 30, now=_local(2026, 8, 26, 22, 15)) - == "wake-20260826-0730" - ) - - -def test_correlation_id_for_ignores_the_time_of_now() -> None: - """Only *now*'s date matters; its own time is replaced.""" - early = correlation_id_for(6, 0, now=_local(2026, 8, 26, 1, 2)) - late = correlation_id_for(6, 0, now=_local(2026, 8, 26, 23, 58)) - assert early == late == "wake-20260826-0600" - - -def test_today_id_comes_from_the_alarm_not_the_start_instant( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A late start must not shift the id away from the phone's. - - The alarm can begin a full catch-up window late, or hours late on a - re-fire. Both halves derive the id independently with no handshake, so - deriving the PC's from *now* rather than from the resolved alarm time - would silently stop the two log streams joining. - """ - monkeypatch.setattr(_correlation, "get_alarm", lambda: Alarm(6, 45, "remote")) - today = datetime.now().astimezone() - - # Started at 07:35 -- 50 minutes into the catch-up window. - assert _correlation.correlation_id_for_today() == correlation_id_for( - 6, 45, now=today - ) - assert _correlation.correlation_id_for_today().endswith("-0645") diff --git a/wake_alarm/tests/test_curtains.py b/wake_alarm/tests/test_curtains.py deleted file mode 100644 index f778a12..0000000 --- a/wake_alarm/tests/test_curtains.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Tests for the curtain driver the alarm calls on its wake sequence.""" - -from __future__ import annotations - -import json -from typing import TYPE_CHECKING - -import pytest - -from wake_alarm import _curtains -from wake_alarm._curtains import NoOpCurtains, get_driver, open_curtains - -if TYPE_CHECKING: - from pathlib import Path - - -@pytest.fixture(autouse=True) -def config_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Point the config at a temporary path, never the real one.""" - path = tmp_path / "curtains.json" - monkeypatch.setattr(_curtains, "CURTAINS_CONFIG_FILE", path) - return path - - -def _write(path: Path, payload: object) -> None: - """Write *payload* as the curtain config.""" - path.write_text(json.dumps(payload)) - - -class TestNoOpDriver: - """The default driver on a machine with no curtain motor.""" - - def test_open_reports_success(self, caplog: pytest.LogCaptureFixture) -> None: - """Logging the intent counts as success — there is nothing to fail.""" - with caplog.at_level("INFO"): - assert NoOpCurtains().open() is True - assert "would open" in caplog.text - - def test_close_reports_success(self, caplog: pytest.LogCaptureFixture) -> None: - """The close half exists so a driver swap has both ends to implement.""" - with caplog.at_level("INFO"): - assert NoOpCurtains().close() is True - assert "would close" in caplog.text - - -class TestDriverSelection: - """Choosing a driver from config, and every way that can go wrong.""" - - def test_no_config_means_the_noop(self) -> None: - """The ordinary case today: no file, no hardware, no error.""" - assert isinstance(get_driver(), NoOpCurtains) - - def test_explicit_noop(self, config_file: Path) -> None: - """Naming the default explicitly works too.""" - _write(config_file, {"driver": "noop"}) - assert isinstance(get_driver(), NoOpCurtains) - - def test_unknown_driver_falls_back( - self, config_file: Path, caplog: pytest.LogCaptureFixture - ) -> None: - """A typo must not stop the alarm; it degrades and says so loudly.""" - _write(config_file, {"driver": "definitely-not-a-motor"}) - with caplog.at_level("WARNING"): - assert isinstance(get_driver(), NoOpCurtains) - assert "Unknown curtain driver" in caplog.text - - def test_non_string_driver_is_ignored(self, config_file: Path) -> None: - """A JSON number where a name belongs is not a driver.""" - _write(config_file, {"driver": 42}) - assert isinstance(get_driver(), NoOpCurtains) - - def test_malformed_json_falls_back( - self, config_file: Path, caplog: pytest.LogCaptureFixture - ) -> None: - """A half-written config means no curtains, not a crash.""" - config_file.write_text("{not json") - with caplog.at_level("WARNING"): - assert isinstance(get_driver(), NoOpCurtains) - assert "Cannot read" in caplog.text - - def test_non_dict_config_falls_back( - self, config_file: Path, caplog: pytest.LogCaptureFixture - ) -> None: - """A JSON list is not a config object.""" - _write(config_file, ["noop"]) - with caplog.at_level("WARNING"): - assert isinstance(get_driver(), NoOpCurtains) - assert "not a JSON object" in caplog.text - - -class TestOpenCurtains: - """The entry point the alarm's startup path calls.""" - - def test_opens_via_the_configured_driver(self) -> None: - """The ordinary path.""" - assert open_curtains() is True - - def test_a_failing_driver_never_stops_the_alarm( - self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture - ) -> None: - """A jammed motor is a curtain problem, not an alarm problem. - - The call sits on the startup path, so an exception escaping here would - take the whole alarm down and the user would not be woken at all. - """ - - class Jammed: - def open(self) -> bool: - msg = "the motor is stuck" - raise RuntimeError(msg) - - def close(self) -> bool: - return False - - monkeypatch.setattr(_curtains, "get_driver", Jammed) - with caplog.at_level("WARNING"): - assert open_curtains() is False - assert "the alarm continues" in caplog.text diff --git a/wake_alarm/tests/test_escalation.py b/wake_alarm/tests/test_escalation.py deleted file mode 100644 index e5bbf3f..0000000 --- a/wake_alarm/tests/test_escalation.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Tests for the beep escalation ladder and the audio failsafe.""" - -from __future__ import annotations - -import pytest - -from wake_alarm._constants import ( - ESCAPE_OFFER_AFTER_SECONDS, - FAILSAFE_SILENCE_SECONDS, - PHASE_MEDIUM_END, - PHASE_SOFT_END, -) -from wake_alarm._escalation import ( - LOUD_HIGH_FREQUENCY, - LOUD_LOW_FREQUENCY, - MEDIUM_FREQUENCY, - SOFT_FREQUENCY, - plan_for, -) - - -def test_starts_on_the_soft_rung() -> None: - """The first seconds are the gentlest tone.""" - plan = plan_for(0.0) - assert plan.frequency == SOFT_FREQUENCY - assert plan.phase == "soft" - - -def test_escalates_to_medium() -> None: - """Crossing the soft boundary raises the pitch.""" - assert plan_for(PHASE_SOFT_END * 60).frequency == MEDIUM_FREQUENCY - - -def test_escalates_to_loud() -> None: - """Crossing the medium boundary reaches the loud rung.""" - assert plan_for(PHASE_MEDIUM_END * 60).phase == "loud" - - -def test_loud_phase_alternates_between_two_tones() -> None: - """A changing pitch stays salient where a constant one is tuned out.""" - seen = {plan_for(PHASE_MEDIUM_END * 60 + offset).frequency for offset in range(20)} - assert seen == {LOUD_LOW_FREQUENCY, LOUD_HIGH_FREQUENCY} - - -def test_intervals_shorten_as_it_escalates() -> None: - """Each rung beeps more often than the last.""" - soft = plan_for(0.0).interval - medium = plan_for(PHASE_SOFT_END * 60).interval - loud = plan_for(PHASE_MEDIUM_END * 60).interval - assert soft > medium > loud - - -def test_failsafe_silences_audio_at_the_threshold() -> None: - """At the failsafe the tone stops.""" - plan = plan_for(FAILSAFE_SILENCE_SECONDS) - assert plan.frequency is None - assert plan.silenced is True - - -def test_audio_still_sounds_just_before_the_failsafe() -> None: - """The boundary is exclusive; a moment earlier the alarm is still loud.""" - plan = plan_for(FAILSAFE_SILENCE_SECONDS - 1) - assert plan.frequency is not None - assert plan.silenced is False - - -@pytest.mark.parametrize("extra", [1, 60, 600, 3600]) -def test_stays_silent_after_the_failsafe(extra: int) -> None: - """Silence is permanent for the rest of the ring; it never resumes.""" - assert plan_for(FAILSAFE_SILENCE_SECONDS + extra).silenced is True - - -def test_reaches_the_loud_phase_before_the_failsafe() -> None: - """The whole point: the alarm must get maximally annoying before it stops. - - A regression here would silently reduce the alarm to its softest tone, - which is exactly the bug this ordering exists to prevent. - """ - assert PHASE_MEDIUM_END * 60 < FAILSAFE_SILENCE_SECONDS - - -def test_spends_most_of_the_ring_on_the_loud_rung() -> None: - """Loud should dominate the audible window, not be a brief tail.""" - loud_seconds = FAILSAFE_SILENCE_SECONDS - PHASE_MEDIUM_END * 60 - assert loud_seconds >= FAILSAFE_SILENCE_SECONDS / 2 - - -def test_escape_hatch_does_not_open_when_the_sound_stops() -> None: - """Offering the hatch exactly as the alarm goes quiet makes it too easy.""" - assert ESCAPE_OFFER_AFTER_SECONDS != FAILSAFE_SILENCE_SECONDS - assert ESCAPE_OFFER_AFTER_SECONDS > FAILSAFE_SILENCE_SECONDS diff --git a/wake_alarm/tests/test_escape_flow.py b/wake_alarm/tests/test_escape_flow.py deleted file mode 100644 index efef47b..0000000 --- a/wake_alarm/tests/test_escape_flow.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Tests for the alarm's escape hatch. - -The alarm was the only locker with no way out but the challenge itself. These -tests pin the two properties that make the new hatch safe: it is never the -*easy* first move, and it cannot be leaned on repeatedly. -""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from gatelock import EscapePolicy, EscapeTracker -import pytest - -from wake_alarm._constants import ESCAPE_OFFER_AFTER_SECONDS -from wake_alarm._escape_flow import ( - ALARM_ESCAPE_POLICY, - EscapeCallbacks, - EscapeDialog, - build_tracker, - is_offerable, -) - -if TYPE_CHECKING: - from collections.abc import Generator - from pathlib import Path - -TODAY = "2026-07-25" - - -@pytest.fixture -def tracker(tmp_path: Path) -> EscapeTracker: - """A tracker over a throwaway history and key.""" - key = tmp_path / "hmac.key" - key.write_bytes(b"0" * 32) - return EscapeTracker( - EscapePolicy(name="cant-wake", label="Can't-wake"), - tmp_path / "escape.json", - key_file=key, - ) - - -# --------------------------------------------------------------------------- -# Tk stand-ins (duplicated from the part files so this one is self-contained; -# they are autouse there, and hoisting them into conftest would silently -# neuter the audio and sync suites) -# --------------------------------------------------------------------------- - - -def _make_mock_tk() -> MagicMock: - """Build a MagicMock that stands in for the tkinter module. - - ``Button`` gets a ``side_effect`` rather than a single ``return_value``: - every surface must end up with its *own* button, otherwise - ``escape_buttons`` is N references to one mock and "packed on every - monitor" cannot be told apart from "packed once". - """ - mock = MagicMock() - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - mock.Frame.side_effect = lambda *_args, **_kwargs: MagicMock() - mock.Label.return_value = MagicMock() - mock.Entry.return_value = MagicMock() - mock.Button.side_effect = lambda *_args, **_kwargs: MagicMock() - mock.TclError = tk.TclError - mock.END = tk.END - return mock - - -@pytest.fixture(autouse=True) -def _block_real_tk() -> Generator[MagicMock]: - """Prevent any real Tk windows in tests.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch("wake_alarm._alarm.GateRoot", return_value=mock.Tk.return_value), - ): - yield mock - - -@pytest.fixture(autouse=True) -def _block_extra_devices() -> Generator[MagicMock]: - """Prevent real subprocess.Popen calls for extra ALSA devices.""" - with ( - patch("wake_alarm._beep_loop._play_on_all_sinks") as mock, - patch("wake_alarm._startup._set_max_brightness"), - patch("wake_alarm._startup._wake_display"), - patch("wake_alarm._alarm._restore_display"), - patch("wake_alarm._startup._warn_if_no_real_sink"), - patch("wake_alarm._alarm._activate_all_outputs", return_value=None), - patch("wake_alarm._alarm._restore_audio_state"), - patch("wake_alarm._startup.turn_on_plug"), - patch("wake_alarm._alarm.turn_off_plug"), - ): - yield mock - - -class TestPolicy: - """The alarm's hatch is exactly as hard as sick mode.""" - - def test_matches_sick_mode_defaults(self) -> None: - """No softer budgets, no shorter wait, no shorter justification.""" - assert ALARM_ESCAPE_POLICY.budget_per_7_days == 1 - assert ALARM_ESCAPE_POLICY.budget_per_30_days == 3 - assert ALARM_ESCAPE_POLICY.budget_per_90_days == 10 - assert ALARM_ESCAPE_POLICY.lockout_seconds == 120 - assert ALARM_ESCAPE_POLICY.justification_min_chars == 120 - - -class TestIsOfferable: - """Two independent gates.""" - - def test_hidden_early(self, tracker: EscapeTracker) -> None: - """Reaching for it immediately is not allowed.""" - assert is_offerable(tracker, 5) is False - - def test_shown_after_the_threshold(self, tracker: EscapeTracker) -> None: - """Once the alarm has been up a while, the hatch appears.""" - assert is_offerable(tracker, ESCAPE_OFFER_AFTER_SECONDS + 1) is True - - def test_hidden_when_budget_spent(self, tracker: EscapeTracker) -> None: - """A spent budget removes it entirely, however long you wait.""" - tracker.history.used_days = [TODAY] - with patch("gatelock._escape._today_iso", return_value=TODAY): - assert is_offerable(tracker, 10_000) is False - - def test_build_tracker_loads(self, tmp_path: Path) -> None: - """The real builder points at the config dir and loads cleanly.""" - with patch("wake_alarm._escape_flow.ESCAPE_HISTORY_FILE", tmp_path / "h.json"): - assert build_tracker().history.used_days == [] - - -class TestEscapeDialog: - """The justification form.""" - - def _dialog( - self, tracker: EscapeTracker - ) -> tuple[EscapeDialog, MagicMock, MagicMock]: - granted, cancelled = MagicMock(), MagicMock() - with patch("wake_alarm._escape_dialog.tk") as fake_tk: - # Distinct entries, or all three fields are one mock and a test - # setting reason/onset/severity separately would only be setting - # the last of them three times over. - fake_tk.Entry.side_effect = lambda *_a, **_k: MagicMock() - fake_tk.Text.return_value.get.return_value = "x" * 130 - dialog = EscapeDialog( - MagicMock(), - MagicMock(), - tracker, - EscapeCallbacks(on_granted=granted, on_cancel=cancelled), - ) - return dialog, granted, cancelled - - def test_rejects_a_lazy_justification(self, tracker: EscapeTracker) -> None: - """A short description is refused and nothing is recorded.""" - dialog, granted, _cancel = self._dialog(tracker) - dialog._fields.description.get.return_value = "too short" - dialog._fields.reason.get.return_value = "tired" - dialog._fields.onset.get.return_value = "now" - dialog._fields.severity.get.return_value = "5" - - dialog.submit() - - granted.assert_not_called() - assert tracker.history.used_days == [] - - def test_non_numeric_severity_is_refused(self, tracker: EscapeTracker) -> None: - """A non-numeric severity becomes 0, which fails validation.""" - dialog, granted, _cancel = self._dialog(tracker) - dialog._fields.description.get.return_value = "x" * 130 - dialog._fields.reason.get.return_value = "flu" - dialog._fields.onset.get.return_value = "03:00" - dialog._fields.severity.get.return_value = "very" - - dialog.submit() - - granted.assert_not_called() - - def test_accepts_and_starts_the_wait(self, tracker: EscapeTracker) -> None: - """A proper justification is recorded and starts the countdown.""" - dialog, granted, _cancel = self._dialog(tracker) - dialog._fields.description.get.return_value = "x" * 130 - dialog._fields.reason.get.return_value = "migraine" - dialog._fields.onset.get.return_value = "03:00" - dialog._fields.severity.get.return_value = "8" - - dialog.submit() - - assert len(tracker.history.used_days) == 1 - assert dialog._remaining >= 0 - granted.assert_not_called() # only after the wait - - def test_refuses_when_budget_spent_mid_form(self, tracker: EscapeTracker) -> None: - """The budget is re-checked at submit, not just when the form opened.""" - dialog, granted, _cancel = self._dialog(tracker) - dialog._fields.description.get.return_value = "x" * 130 - dialog._fields.reason.get.return_value = "flu" - dialog._fields.onset.get.return_value = "03:00" - dialog._fields.severity.get.return_value = "8" - tracker.history.used_days = [TODAY] - - with patch("gatelock._escape._today_iso", return_value=TODAY): - dialog.submit() - - granted.assert_not_called() - - def test_tick_counts_down_then_grants(self, tracker: EscapeTracker) -> None: - """The wait elapses exactly once, then the alarm is released.""" - dialog, granted, _cancel = self._dialog(tracker) - dialog._remaining = 0 - - dialog._tick() - - granted.assert_called_once_with() - - def test_tick_reschedules_while_waiting(self, tracker: EscapeTracker) -> None: - """A remaining wait re-arms itself instead of granting.""" - dialog, granted, _cancel = self._dialog(tracker) - dialog._remaining = 3 - - dialog._tick() - - granted.assert_not_called() - dialog.frame.after.assert_called_once() diff --git a/wake_alarm/tests/test_escape_integration.py b/wake_alarm/tests/test_escape_integration.py deleted file mode 100644 index b1e31cf..0000000 --- a/wake_alarm/tests/test_escape_integration.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Tests wiring the escape hatch into the running alarm. - -Split from ``test_escape_flow`` for the 250-line cap: that module covers the -hatch in isolation, this one covers it mounted on a real two-monitor alarm. -""" - -from __future__ import annotations - -import tkinter as tk -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from gatelock import EscapePolicy, EscapeTracker -import pytest - -from wake_alarm._alarm import WakeAlarm -from wake_alarm._constants import ESCAPE_OFFER_AFTER_SECONDS -from wake_alarm.tests.conftest import TWO_OUTPUTS - -if TYPE_CHECKING: - from collections.abc import Generator - from pathlib import Path - -TODAY = "2026-07-25" - - -@pytest.fixture -def tracker(tmp_path: Path) -> EscapeTracker: - """A tracker over a throwaway history and key.""" - key = tmp_path / "hmac.key" - key.write_bytes(b"0" * 32) - return EscapeTracker( - EscapePolicy(name="cant-wake", label="Can't-wake"), - tmp_path / "escape.json", - key_file=key, - ) - - -# --------------------------------------------------------------------------- -# Tk stand-ins (duplicated from the part files so this one is self-contained; -# they are autouse there, and hoisting them into conftest would silently -# neuter the audio and sync suites) -# --------------------------------------------------------------------------- - - -def _make_mock_tk() -> MagicMock: - """Build a MagicMock that stands in for the tkinter module. - - ``Button`` gets a ``side_effect`` rather than a single ``return_value``: - every surface must end up with its *own* button, otherwise - ``escape_buttons`` is N references to one mock and "packed on every - monitor" cannot be told apart from "packed once". - """ - mock = MagicMock() - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - mock.Frame.side_effect = lambda *_args, **_kwargs: MagicMock() - mock.Label.return_value = MagicMock() - mock.Entry.return_value = MagicMock() - mock.Button.side_effect = lambda *_args, **_kwargs: MagicMock() - mock.TclError = tk.TclError - mock.END = tk.END - return mock - - -@pytest.fixture(autouse=True) -def _block_real_tk() -> Generator[MagicMock]: - """Prevent any real Tk windows in tests.""" - mock = _make_mock_tk() - with ( - patch("wake_alarm._alarm.tk", mock), - patch("wake_alarm._dismiss_view.tk", mock), - patch("wake_alarm._alarm_view.tk", mock), - patch("wake_alarm._alarm.GateRoot", return_value=mock.Tk.return_value), - ): - yield mock - - -@pytest.fixture(autouse=True) -def _block_extra_devices() -> Generator[MagicMock]: - """Prevent real subprocess.Popen calls for extra ALSA devices.""" - with ( - patch("wake_alarm._beep_loop._play_on_all_sinks") as mock, - patch("wake_alarm._startup._set_max_brightness"), - patch("wake_alarm._startup._wake_display"), - patch("wake_alarm._alarm._restore_display"), - patch("wake_alarm._startup._warn_if_no_real_sink"), - patch("wake_alarm._alarm._activate_all_outputs", return_value=None), - patch("wake_alarm._alarm._restore_audio_state"), - patch("wake_alarm._startup.turn_on_plug"), - patch("wake_alarm._alarm.turn_off_plug"), - ): - yield mock - - -@pytest.mark.usefixtures("dual_output") -class TestAlarmIntegration: - """The hatch as the alarm actually uses it, on a two-monitor desk.""" - - def test_every_output_gets_its_own_button(self) -> None: - """The premise the rest of this class rests on: two distinct buttons.""" - alarm = WakeAlarm(demo_mode=True) - - buttons = alarm._view.escape_buttons - assert len(buttons) == len(TWO_OUTPUTS) - assert len({id(button) for button in buttons}) == len(TWO_OUTPUTS) - alarm._stop_beep.set() - - def test_button_hidden_until_earned(self) -> None: - """Straight after the alarm starts, the hatch is not offered.""" - alarm = WakeAlarm(demo_mode=True) - for button in alarm._view.escape_buttons: - button.pack.reset_mock() - - alarm._update_timer() - - for button in alarm._view.escape_buttons: - button.pack.assert_not_called() - alarm._stop_beep.set() - - def test_button_revealed_once_earned(self) -> None: - """After the threshold the button appears on every monitor. - - Driven through ``_update_timer`` rather than the hatch directly, so - the per-second tick is on the hook for ever offering the hatch at all. - """ - alarm = WakeAlarm(demo_mode=True) - alarm._progress.started_at -= ESCAPE_OFFER_AFTER_SECONDS + 10 - for button in alarm._view.escape_buttons: - button.pack.reset_mock() - - alarm._update_timer() - - for button in alarm._view.escape_buttons: - button.pack.assert_called() - alarm._stop_beep.set() - - def test_not_reoffered_while_open(self) -> None: - """With the form already up, the button is not re-packed.""" - alarm = WakeAlarm(demo_mode=True) - alarm._progress.started_at -= ESCAPE_OFFER_AFTER_SECONDS + 10 - alarm._escape.is_open = True - for button in alarm._view.escape_buttons: - button.pack.reset_mock() - - alarm._update_timer() - - for button in alarm._view.escape_buttons: - button.pack.assert_not_called() - alarm._stop_beep.set() - - def test_open_swaps_every_surface(self) -> None: - """Opening the form hides the challenge and builds a form per monitor.""" - alarm = WakeAlarm(demo_mode=True) - surfaces = alarm._view.surfaces - assert len(surfaces) == len(TWO_OUTPUTS) - - with patch("wake_alarm._escape_flow.EscapeDialog") as dialog_cls: - alarm._escape.open() - - assert alarm._escape.is_open is True - assert dialog_cls.call_count == len(surfaces) - # One dialog *per* container, not two against the same parent. - parents = [call.args[0] for call in dialog_cls.call_args_list] - assert {id(parent) for parent in parents} == { - id(view.container) for view in surfaces - } - for view in surfaces: - view.challenge_frame.pack_forget.assert_called_once_with() - alarm._stop_beep.set() - - def test_a_dead_surface_does_not_block_the_others(self) -> None: - """A monitor that has gone away raises TclError; the rest still swap.""" - alarm = WakeAlarm(demo_mode=True) - dead, live = alarm._view.surfaces - dead.challenge_frame.pack_forget.side_effect = tk.TclError("bad path name") - - with patch("wake_alarm._escape_flow.EscapeDialog") as dialog_cls: - alarm._escape.open() - - assert dialog_cls.call_count == 1 - assert dialog_cls.call_args.args[0] is live.container - alarm._stop_beep.set() - - def test_cancel_puts_the_challenge_back(self) -> None: - """Backing out destroys every form and re-packs every challenge. - - The bool alone is not the property that matters: ``open`` took the - challenge off the screen, so a cancel that only flips a flag leaves - the alarm showing a dead form on both monitors. - """ - alarm = WakeAlarm(demo_mode=True) - surfaces = alarm._view.surfaces - dialogs: list[MagicMock] = [] - - def make_dialog(*_args: object, **_kwargs: object) -> MagicMock: - dialog = MagicMock() - dialogs.append(dialog) - return dialog - - with patch("wake_alarm._escape_flow.EscapeDialog", side_effect=make_dialog): - alarm._escape.open() - # The frame was packed once when the view was built; only the re-pack - # that close() owes us should count. - for view in surfaces: - view.challenge_frame.pack.reset_mock() - - alarm._escape.close() - - assert alarm._escape.is_open is False - assert alarm.dismissed is False - assert len(dialogs) == len(TWO_OUTPUTS) - for dialog in dialogs: - dialog.frame.destroy.assert_called_once_with() - for view in surfaces: - view.challenge_frame.pack.assert_called_once_with() - alarm._stop_beep.set() - - def test_granted_dismisses_without_a_skip(self) -> None: - """Using the hatch silences the alarm but earns no workout skip.""" - alarm = WakeAlarm(demo_mode=True) - - with patch.object(alarm, "_dismiss_alarm") as dismiss: - alarm._on_escape_granted() - - dismiss.assert_called_once_with(earned_skip=False) - alarm._stop_beep.set() diff --git a/wake_alarm/tests/test_gating.py b/wake_alarm/tests/test_gating.py deleted file mode 100644 index df72f77..0000000 --- a/wake_alarm/tests/test_gating.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Tests for the day/dismissed/workout gate.""" - -from __future__ import annotations - -from unittest.mock import patch - -from wake_alarm._gating import ( - should_run_alarm as _should_run_alarm, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -class TestShouldRunAlarm: - """Tests for _should_run_alarm.""" - - def test_returns_false_on_non_alarm_day(self) -> None: - """Return False when today is not an alarm day.""" - with patch( - "wake_alarm._gating.is_alarm_day", - return_value=False, - ): - assert _should_run_alarm() is False - - def test_returns_false_when_already_dismissed(self) -> None: - """Return False when alarm was already dismissed today.""" - with ( - patch( - "wake_alarm._gating.is_alarm_day", - return_value=True, - ), - patch( - "wake_alarm._gating.was_alarm_dismissed_today", - return_value=True, - ), - ): - assert _should_run_alarm() is False - - def test_returns_true_when_alarm_day_and_not_dismissed(self) -> None: - """Return True when today is alarm day and not yet dismissed.""" - with ( - patch( - "wake_alarm._gating.is_alarm_day", - return_value=True, - ), - patch( - "wake_alarm._gating.was_alarm_dismissed_today", - return_value=False, - ), - patch( - "wake_alarm._gating.was_workout_logged_today", - return_value=False, - ), - ): - assert _should_run_alarm() is True - - def test_returns_false_when_workout_already_logged(self) -> None: - """Return False when workout was already logged today.""" - with ( - patch( - "wake_alarm._gating.is_alarm_day", - return_value=True, - ), - patch( - "wake_alarm._gating.was_alarm_dismissed_today", - return_value=False, - ), - patch( - "wake_alarm._gating.was_workout_logged_today", - return_value=True, - ), - ): - assert _should_run_alarm() is False diff --git a/wake_alarm/tests/test_inhibit.py b/wake_alarm/tests/test_inhibit.py deleted file mode 100644 index 7e19232..0000000 --- a/wake_alarm/tests/test_inhibit.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Tests for the power-key inhibitor held while the alarm rings.""" - -from __future__ import annotations - -import subprocess -from unittest.mock import MagicMock - -import pytest - -from wake_alarm import _inhibit - - -@pytest.fixture -def _no_stale_scan(monkeypatch: pytest.MonkeyPatch) -> None: - """Stub the stale scan, so taking an inhibitor never scans the real host. - - Not autouse: TestStaleCleanup exercises the real function, and an autouse - stub would silently make every one of those tests assert nothing. - """ - monkeypatch.setattr(_inhibit, "kill_stale_inhibitors", lambda: 0) - - -def test_resolves_systemd_inhibit_from_path( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The binary is looked up on PATH, never hardcoded.""" - monkeypatch.setattr(_inhibit.shutil, "which", lambda name: f"/usr/bin/{name}") - assert _inhibit._systemd_inhibit_path() == "/usr/bin/systemd-inhibit" - - -@pytest.mark.usefixtures("_no_stale_scan") -class TestTakeInhibitor: - """Taking the inhibitor that blocks the power button.""" - - def test_spawns_a_blocking_inhibitor(self, monkeypatch: pytest.MonkeyPatch) -> None: - """The child blocks handle-power-key and self-terminates.""" - monkeypatch.setattr( - _inhibit, "_systemd_inhibit_path", lambda: "/usr/bin/systemd-inhibit" - ) - captured: list[list[str]] = [] - - def fake_popen(cmd: list[str], **_kwargs: object) -> MagicMock: - captured.append(cmd) - live = MagicMock(pid=4242) - # Still running after the confirm wait: logind accepted it. - live.wait.side_effect = subprocess.TimeoutExpired("systemd-inhibit", 0.5) - return live - - monkeypatch.setattr(_inhibit.subprocess, "Popen", fake_popen) - - assert _inhibit.take_power_key_inhibitor() is not None - - cmd = captured[0] - assert "--mode=block" in cmd - assert "--what=handle-power-key:shutdown:idle" in cmd - # Never `sleep infinity`: an orphaned blocker would hold the power - # button down until the next reboot. - assert cmd[-2] == "sleep" - assert int(cmd[-1]) > 0 - - def test_missing_binary_never_stops_the_alarm( - self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture - ) -> None: - """No systemd-inhibit means an unprotected alarm, not no alarm.""" - monkeypatch.setattr(_inhibit, "_systemd_inhibit_path", lambda: None) - with caplog.at_level("WARNING"): - assert _inhibit.take_power_key_inhibitor() is None - assert "systemd-inhibit not on PATH" in caplog.text - - def test_a_refused_inhibit_is_reported_as_none( - self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture - ) -> None: - """Claiming a block we did not get is worse than reporting none. - - Popen succeeding only means the *binary* launched. logind can still - refuse -- polkit denies a non-interactive session outright, which a - vmbox run turned up: systemd-inhibit printed "Failed to inhibit: - Access denied" and exited 1 while the alarm logged the power key as - blocked. - """ - monkeypatch.setattr( - _inhibit, "_systemd_inhibit_path", lambda: "/usr/bin/systemd-inhibit" - ) - refused = MagicMock(returncode=1) - refused.wait.return_value = 1 - refused.stderr.read.return_value = b"Failed to inhibit: Access denied" - monkeypatch.setattr(_inhibit.subprocess, "Popen", lambda *a, **k: refused) - with caplog.at_level("WARNING"): - assert _inhibit.take_power_key_inhibitor() is None - assert "logind refused" in caplog.text - assert "Access denied" in caplog.text - - def test_a_refusal_with_no_stderr_still_reports( - self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture - ) -> None: - """A silent refusal is still a refusal, and still must not be claimed.""" - monkeypatch.setattr( - _inhibit, "_systemd_inhibit_path", lambda: "/usr/bin/systemd-inhibit" - ) - refused = MagicMock(returncode=1, stderr=None) - refused.wait.return_value = 1 - monkeypatch.setattr(_inhibit.subprocess, "Popen", lambda *a, **k: refused) - with caplog.at_level("WARNING"): - assert _inhibit.take_power_key_inhibitor() is None - assert "no detail" in caplog.text - - def test_spawn_failure_is_survivable( - self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture - ) -> None: - """A failed spawn is logged and the alarm rings on.""" - monkeypatch.setattr( - _inhibit, "_systemd_inhibit_path", lambda: "/usr/bin/systemd-inhibit" - ) - - def boom(_cmd: list[str], **_kwargs: object) -> MagicMock: - msg = "no fork for you" - raise OSError(msg) - - monkeypatch.setattr(_inhibit.subprocess, "Popen", boom) - with caplog.at_level("WARNING"): - assert _inhibit.take_power_key_inhibitor() is None - assert "rings unprotected" in caplog.text - - def test_clears_a_stale_inhibitor_first( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """A blocker from a crashed alarm is cleared before a new one is taken.""" - monkeypatch.setattr( - _inhibit, "_systemd_inhibit_path", lambda: "/usr/bin/systemd-inhibit" - ) - monkeypatch.setattr(_inhibit.subprocess, "Popen", lambda *a, **k: MagicMock()) - cleared: list[str] = [] - - def fake_clear() -> int: - cleared.append("cleared") - return 0 - - monkeypatch.setattr(_inhibit, "kill_stale_inhibitors", fake_clear) - _inhibit.take_power_key_inhibitor() - assert cleared == ["cleared"] - - -class TestReleaseInhibitor: - """Releasing the inhibitor when the alarm ends.""" - - def test_none_is_a_no_op(self) -> None: - """on_close() runs on every path, including ones that took nothing.""" - _inhibit.release_power_key_inhibitor(None) - - def test_already_exited_is_a_no_op(self) -> None: - """A child that self-terminated is not signalled again.""" - process = MagicMock() - process.poll.return_value = 0 - _inhibit.release_power_key_inhibitor(process) - process.terminate.assert_not_called() - - def test_terminates_a_live_inhibitor(self) -> None: - """The ordinary path: SIGTERM, then reap.""" - process = MagicMock() - process.poll.return_value = None - _inhibit.release_power_key_inhibitor(process) - process.terminate.assert_called_once_with() - process.wait.assert_called_once_with(timeout=5) - process.kill.assert_not_called() - - def test_kills_an_inhibitor_that_ignores_sigterm( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """A stuck child must not outlive the alarm holding the power key.""" - process = MagicMock() - process.poll.return_value = None - process.wait.side_effect = subprocess.TimeoutExpired("systemd-inhibit", 5) - with caplog.at_level("WARNING"): - _inhibit.release_power_key_inhibitor(process) - process.kill.assert_called_once_with() - assert "ignored SIGTERM" in caplog.text diff --git a/wake_alarm/tests/test_inhibit_cleanup.py b/wake_alarm/tests/test_inhibit_cleanup.py deleted file mode 100644 index 55a20d1..0000000 --- a/wake_alarm/tests/test_inhibit_cleanup.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Tests for clearing a power-key inhibitor orphaned by a crashed alarm. - -Split from ``test_inhibit.py`` for the 250-line cap. The header, including the -copied whole, minus ``test_inhibit.py``'s ``_no_stale_scan`` stub: these tests -exercise the *real* cleanup, and stubbing it out would make every one of them -assert nothing. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import MagicMock - -from wake_alarm import _inhibit -from wake_alarm._constants import INHIBIT_WHO - -if TYPE_CHECKING: - from pathlib import Path - - import pytest - - -class TestStaleCleanup: - """Clearing an inhibitor orphaned by a crashed alarm.""" - - def test_identifies_our_own_child( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """A pid is judged by its argv, never by a command-line pattern. - - The earlier ``pkill -f`` version matched the shell that had just typed - the pattern and killed *it* -- substring matching is self-matching. - """ - proc = tmp_path / "77" - proc.mkdir() - (proc / "cmdline").write_bytes( - b"systemd-inhibit\x00--who=" + INHIBIT_WHO.encode() + b"\x00" - ) - monkeypatch.setattr( - _inhibit.pathlib, "Path", lambda *p: tmp_path / p[-2] / p[-1] - ) - assert _inhibit._is_our_inhibitor("77") is True - - def test_ignores_a_pid_that_vanished( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """A process that exited between listing and reading is not stale.""" - monkeypatch.setattr( - _inhibit.pathlib, "Path", lambda *p: tmp_path / "definitely-absent" - ) - assert _inhibit._is_our_inhibitor("99") is False - - def test_kills_only_matching_pids(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Someone else's systemd-inhibit is left strictly alone.""" - monkeypatch.setattr(_inhibit.shutil, "which", lambda _name: "/usr/bin/pgrep") - monkeypatch.setattr( - _inhibit.subprocess, - "run", - lambda *a, **k: MagicMock(stdout=b"11\n22\n"), - ) - monkeypatch.setattr(_inhibit, "_is_our_inhibitor", lambda pid: pid == "22") - signalled: list[int] = [] - monkeypatch.setattr( - _inhibit.os, "kill", lambda pid, _sig: signalled.append(pid) - ) - assert _inhibit.kill_stale_inhibitors() == 1 - assert signalled == [22] - - def test_survives_a_pid_that_exits_mid_kill( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """A race between listing and killing is not an error.""" - monkeypatch.setattr(_inhibit.shutil, "which", lambda _name: "/usr/bin/pgrep") - monkeypatch.setattr( - _inhibit.subprocess, "run", lambda *a, **k: MagicMock(stdout=b"11\n") - ) - monkeypatch.setattr(_inhibit, "_is_our_inhibitor", lambda _pid: True) - - def gone(_pid: int, _sig: int) -> None: - raise ProcessLookupError - - monkeypatch.setattr(_inhibit.os, "kill", gone) - assert _inhibit.kill_stale_inhibitors() == 0 - - def test_no_pgrep_means_no_scan(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Without pgrep the cleanup is skipped rather than guessed at.""" - monkeypatch.setattr(_inhibit.shutil, "which", lambda _name: None) - assert _inhibit.kill_stale_inhibitors() == 0 - - def test_pgrep_failure_is_survivable( - self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture - ) -> None: - """A failed scan must not stop the alarm from starting.""" - monkeypatch.setattr(_inhibit.shutil, "which", lambda _name: "/usr/bin/pgrep") - - def boom(*_a: object, **_k: object) -> None: - msg = "nope" - raise OSError(msg) - - monkeypatch.setattr(_inhibit.subprocess, "run", boom) - with caplog.at_level("WARNING"): - assert _inhibit.kill_stale_inhibitors() == 0 - assert "Could not check for a stale" in caplog.text diff --git a/wake_alarm/tests/test_pcspkr.py b/wake_alarm/tests/test_pcspkr.py deleted file mode 100644 index 1c21e66..0000000 --- a/wake_alarm/tests/test_pcspkr.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Tests for the PC-speaker fallback beep.""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -from wake_alarm._audio import ( - _beep_pcspkr, -) - - -class TestBeepPcspkr: - """Tests for _beep_pcspkr (evdev PC speaker).""" - - def test_writes_tone_then_zero_to_device(self) -> None: - """Successful path writes start-frequency then stop event.""" - - mock_dev = MagicMock() - mock_open_ctx = MagicMock() - mock_open_ctx.__enter__.return_value = mock_dev - mock_open_ctx.__exit__.return_value = False - with ( - patch( - "wake_alarm._tone.Path.open", - return_value=mock_open_ctx, - ), - patch("wake_alarm._audio.time.sleep"), - ): - _beep_pcspkr(1000, 0.05) - # First write carries the frequency, second write carries 0 (stop). - assert mock_dev.write.call_count == 2 - - def test_oserror_is_swallowed(self) -> None: - """OSError opening the device must not raise.""" - - with patch( - "wake_alarm._tone.Path.open", - side_effect=OSError("no device"), - ): - _beep_pcspkr(1000, 0.05) # must not raise diff --git a/wake_alarm/tests/test_reactivate.py b/wake_alarm/tests/test_reactivate.py deleted file mode 100644 index 00c86f7..0000000 --- a/wake_alarm/tests/test_reactivate.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Tests for repeated card-profile reactivation while the alarm rings.""" - -from __future__ import annotations - -import threading -import time - -import pytest - -from wake_alarm import _reactivate -from wake_alarm._reactivate import ( - reactivate_until_stable, - start_background_reactivation, -) - - -@pytest.fixture(autouse=True) -def _fast_schedule(monkeypatch: pytest.MonkeyPatch) -> None: - """Use a short, deterministic retry schedule instead of the real one.""" - monkeypatch.setattr(_reactivate, "REACTIVATE_ATTEMPT_DELAYS", (1.0, 2.0)) - - -def test_runs_one_pass_per_scheduled_delay_plus_the_first() -> None: - """A schedule of two delays means three activation passes.""" - calls: list[int] = [] - reactivate_until_stable( - lambda: calls.append(1), - lambda: ["sink"], - sleep=lambda _d: None, - ) - assert len(calls) == 3 - - -def test_sleeps_for_each_configured_delay() -> None: - """The decaying schedule is honoured, not collapsed into a busy loop.""" - slept: list[float] = [] - reactivate_until_stable( - lambda: None, - list, - sleep=slept.append, - ) - assert slept == [1.0, 2.0] - - -def test_returns_the_final_sink_set() -> None: - """The caller learns which outputs ended up present.""" - result = reactivate_until_stable( - lambda: None, - lambda: ["a", "b"], - sleep=lambda _d: None, - ) - assert result == frozenset({"a", "b"}) - - -def test_reports_a_sink_that_appears_late(caplog: pytest.LogCaptureFixture) -> None: - """The HDMI case: a sink absent at first must be announced when it lands.""" - sequence = [["analog"], ["analog"], ["analog", "hdmi"]] - result = reactivate_until_stable( - lambda: None, - lambda: sequence.pop(0), - sleep=lambda _d: None, - ) - assert result == frozenset({"analog", "hdmi"}) - assert "hdmi" in caplog.text - - -def test_every_pass_logs_the_enumerated_sinks(caplog: pytest.LogCaptureFixture) -> None: - """Each pass records what it saw, so a silent alarm is diagnosable.""" - with caplog.at_level("INFO"): - reactivate_until_stable( - lambda: None, - lambda: ["analog"], - sleep=lambda _d: None, - ) - assert caplog.text.count("Sink reactivation pass") == 3 - - -def test_logs_none_when_no_sink_is_present(caplog: pytest.LogCaptureFixture) -> None: - """An empty enumeration is stated explicitly rather than logged blank.""" - with caplog.at_level("INFO"): - reactivate_until_stable(lambda: None, list, sleep=lambda _d: None) - assert "(none)" in caplog.text - - -def test_stop_event_ends_retrying_before_the_sleep() -> None: - """Dismissing the alarm stops the retry loop promptly.""" - stop = threading.Event() - stop.set() - calls: list[int] = [] - reactivate_until_stable( - lambda: calls.append(1), - list, - sleep=lambda _d: None, - stop=stop, - ) - assert len(calls) == 1 - - -def test_stop_event_set_during_the_sleep_ends_retrying() -> None: - """A dismissal landing mid-delay is noticed on wake, not ignored.""" - stop = threading.Event() - calls: list[int] = [] - reactivate_until_stable( - lambda: calls.append(1), - list, - sleep=lambda _d: stop.set(), - stop=stop, - ) - assert len(calls) == 1 - - -def test_background_thread_runs_the_passes() -> None: - """The helper actually executes the work off the beep loop's thread.""" - done = threading.Event() - stop = threading.Event() - thread = start_background_reactivation( - done.set, - lambda: ["s"], - sleep=lambda _d: None, - stop=stop, - ) - thread.join(timeout=5) - assert done.is_set() - - -def test_background_thread_is_a_daemon() -> None: - """A stuck retry must never keep the process alive after dismissal.""" - stop = threading.Event() - stop.set() - thread = start_background_reactivation( - lambda: None, - list, - sleep=lambda _d: None, - stop=stop, - ) - thread.join(timeout=5) - assert thread.daemon - - -def test_activate_only_discards_the_captured_state( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Retry passes must not hand back a state anyone could restore. - - Only the *first* activation sees the user's real audio settings; every - later pass captures the alarm's own. Returning one would let a future - caller restore the alarm's settings as if they were the user's. - """ - calls: list[str] = [] - - def fake_activate() -> str: - calls.append("activated") - return "prior-state" - - monkeypatch.setattr(_reactivate, "_activate_all_outputs", fake_activate) - _reactivate._activate_outputs_only() - assert calls == ["activated"] - - -def test_list_sinks_now_returns_the_real_sinks( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The zero-arg adapter resolves pactl and forwards the path.""" - monkeypatch.setattr(_reactivate, "_pactl_path", lambda: "/usr/bin/pactl") - monkeypatch.setattr( - _reactivate, - "_list_real_sinks", - lambda pactl: [f"sink-via-{pactl}"], - ) - assert _reactivate._list_real_sinks_now() == ["sink-via-/usr/bin/pactl"] - - -def test_list_sinks_now_is_empty_without_pactl( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A machine with no pactl reports no sinks instead of raising. - - The adapter runs on a background thread nobody is watching, so an - exception there would vanish silently. - """ - monkeypatch.setattr(_reactivate, "_pactl_path", lambda: None) - assert _reactivate._list_real_sinks_now() == [] - - -def test_start_alarm_reactivation_binds_the_real_callables( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The one wiring point passes the real pactl adapters and time.sleep.""" - captured: dict[str, object] = {} - - sentinel = threading.Thread(target=lambda: None) - - def fake_start( - activate: object, - list_sinks: object, - *, - sleep: object, - stop: object, - ) -> threading.Thread: - captured.update( - activate=activate, list_sinks=list_sinks, sleep=sleep, stop=stop - ) - return sentinel - - monkeypatch.setattr(_reactivate, "start_background_reactivation", fake_start) - stop = threading.Event() - - assert _reactivate.start_alarm_reactivation(stop) is sentinel - - assert captured["activate"] is _reactivate._activate_outputs_only - assert captured["list_sinks"] is _reactivate._list_real_sinks_now - assert captured["sleep"] is time.sleep - assert captured["stop"] is stop diff --git a/wake_alarm/tests/test_sinks_activate.py b/wake_alarm/tests/test_sinks_activate.py deleted file mode 100644 index 12c8909..0000000 --- a/wake_alarm/tests/test_sinks_activate.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Tests for forcing every card's output profile on.""" - -from __future__ import annotations - -from unittest.mock import patch - -from wake_alarm._constants import ALARM_AUDIO_SINK -from wake_alarm._sinks import ( - _activate_all_outputs, - _AudioState, - _CardInfo, -) - -# A representative `pactl list cards` block covering every parse branch: -# an HDMI card (off, with an available stereo output profile), an input-only -# card (no output profile), a card block with no Name line (must be skipped). -_CARDS_TEXT = """Card #54 -\tName: alsa_card.hdmi -\tDriver: module-alsa-card.c -\tProfiles: -\t\toff: Off (sinks: 0, sources: 0, priority: 0, available: yes) -\t\toutput:hdmi-stereo: Stereo (sinks: 1, priority: 5900, available: yes) -\t\toutput:hdmi-surround: Surround (sinks: 1, priority: 600, available: no) -\t\tbroken (available: yes) -\tActive Profile: off -Card #55 -\tName: alsa_card.mic -\tProfiles: -\t\toff: Off (sinks: 0, sources: 0, priority: 0, available: yes) -\tActive Profile: off -Card #56 -\tProfiles: -\t\toutput:noname: NoName (available: yes) -\tActive Profile: off -""" - -# A `pactl list sinks` block: a normal sink, plus one whose Volume line has -# no integer (to cover the "no digit" branch), plus a leading non-Name line. -_SINKS_TEXT = """Sink #62 -\tState: SUSPENDED -\tName: alsa_output.analog -\tMute: no -\tVolume: front-left: 65536 / 100% -\tDescription: Built-in Analog -Sink #63 -\tName: alsa_output.weird -\tMute: yes -\tVolume: n/a -""" - - -class TestActivateAllOutputs: - """Tests for _activate_all_outputs.""" - - def test_returns_none_when_pactl_missing(self) -> None: - """No pactl on PATH → returns None without touching audio.""" - with ( - patch("wake_alarm._sinks._pactl_path", return_value=None), - patch("wake_alarm._sinks.subprocess.run") as mock_run, - ): - assert _activate_all_outputs() is None - mock_run.assert_not_called() - - def _sentinel_state(self) -> _AudioState: - return _AudioState( - default_sink="old", - card_profiles={}, - sink_volumes={}, - sink_mutes={}, - ) - - def test_activates_and_prefers_alarm_sink(self) -> None: - """Forces card profiles on, maxes sinks, defaults to ALARM_AUDIO_SINK.""" - state = self._sentinel_state() - cards = { - "card_hdmi": _CardInfo(None, ["output:hdmi-stereo"]), - "card_mic": _CardInfo(None, []), - } - with ( - patch("wake_alarm._sinks._pactl_path", return_value="/usr/bin/pactl"), - patch("wake_alarm._sinks._capture_audio_state", return_value=state), - patch("wake_alarm._sinks._pactl_text", return_value=""), - patch("wake_alarm._sinks._parse_cards", return_value=cards), - patch( - "wake_alarm._sinks._list_real_sinks", - return_value=[ALARM_AUDIO_SINK, "other"], - ), - patch("wake_alarm._sinks._ensure_sink_audible") as mock_audible, - patch("wake_alarm._sinks.subprocess.run") as mock_run, - ): - assert _activate_all_outputs() is state - cmds = [call.args[0] for call in mock_run.call_args_list] - assert [ - "/usr/bin/pactl", - "set-card-profile", - "card_hdmi", - "output:hdmi-stereo", - ] in cmds - # The input-only card (no output profile) is never switched. - assert not any(c[2] == "card_mic" for c in cmds if c[1] == "set-card-profile") - assert ["/usr/bin/pactl", "set-default-sink", ALARM_AUDIO_SINK] in cmds - assert mock_audible.call_count == 2 - - def test_does_not_block_on_sink_enumeration(self) -> None: - """Activation checks present sinks once — no polling/sleeping loop. - - The per-cycle presence check in _play_on_all_sinks is what covers a - slow monitor sink, so activation must not spin waiting for it. - """ - state = self._sentinel_state() - with ( - patch("wake_alarm._sinks._pactl_path", return_value="/usr/bin/pactl"), - patch("wake_alarm._sinks._capture_audio_state", return_value=state), - patch("wake_alarm._sinks._pactl_text", return_value=""), - patch("wake_alarm._sinks._parse_cards", return_value={}), - patch( - "wake_alarm._sinks._list_real_sinks", - return_value=[ALARM_AUDIO_SINK], - ) as mock_list, - patch("wake_alarm._sinks._ensure_sink_audible"), - patch("wake_alarm._sinks.subprocess.run"), - ): - assert _activate_all_outputs() is state - # Enumerated exactly once — no retry/poll loop. - mock_list.assert_called_once() - - def test_falls_back_to_first_sink_when_alarm_sink_absent(self) -> None: - """ALARM_AUDIO_SINK not present → default is the first present sink.""" - state = self._sentinel_state() - with ( - patch("wake_alarm._sinks._pactl_path", return_value="/usr/bin/pactl"), - patch("wake_alarm._sinks._capture_audio_state", return_value=state), - patch("wake_alarm._sinks._pactl_text", return_value=""), - patch("wake_alarm._sinks._parse_cards", return_value={}), - patch("wake_alarm._sinks._list_real_sinks", return_value=["other"]), - patch("wake_alarm._sinks._ensure_sink_audible"), - patch("wake_alarm._sinks.subprocess.run") as mock_run, - ): - assert _activate_all_outputs() is state - cmds = [call.args[0] for call in mock_run.call_args_list] - assert ["/usr/bin/pactl", "set-default-sink", "other"] in cmds - - def test_returns_state_when_no_sink_present(self) -> None: - """No real sink present yet → returns state, no default, no maxing.""" - state = self._sentinel_state() - with ( - patch("wake_alarm._sinks._pactl_path", return_value="/usr/bin/pactl"), - patch("wake_alarm._sinks._capture_audio_state", return_value=state), - patch("wake_alarm._sinks._pactl_text", return_value=""), - patch("wake_alarm._sinks._parse_cards", return_value={}), - patch("wake_alarm._sinks._list_real_sinks", return_value=[]), - patch("wake_alarm._sinks._ensure_sink_audible") as mock_audible, - patch("wake_alarm._sinks.subprocess.run") as mock_run, - ): - assert _activate_all_outputs() is state - mock_audible.assert_not_called() - cmds = [call.args[0] for call in mock_run.call_args_list] - assert not any(c[1] == "set-default-sink" for c in cmds) diff --git a/wake_alarm/tests/test_sinks_restore.py b/wake_alarm/tests/test_sinks_restore.py deleted file mode 100644 index 2ce174d..0000000 --- a/wake_alarm/tests/test_sinks_restore.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Tests for restoring the audio state captured at alarm start.""" - -from __future__ import annotations - -from unittest.mock import patch - -from wake_alarm._sinks import ( - _AudioState, - _restore_audio_state, -) - -# A representative `pactl list cards` block covering every parse branch: -# an HDMI card (off, with an available stereo output profile), an input-only -# card (no output profile), a card block with no Name line (must be skipped). -_CARDS_TEXT = """Card #54 -\tName: alsa_card.hdmi -\tDriver: module-alsa-card.c -\tProfiles: -\t\toff: Off (sinks: 0, sources: 0, priority: 0, available: yes) -\t\toutput:hdmi-stereo: Stereo (sinks: 1, priority: 5900, available: yes) -\t\toutput:hdmi-surround: Surround (sinks: 1, priority: 600, available: no) -\t\tbroken (available: yes) -\tActive Profile: off -Card #55 -\tName: alsa_card.mic -\tProfiles: -\t\toff: Off (sinks: 0, sources: 0, priority: 0, available: yes) -\tActive Profile: off -Card #56 -\tProfiles: -\t\toutput:noname: NoName (available: yes) -\tActive Profile: off -""" - -# A `pactl list sinks` block: a normal sink, plus one whose Volume line has -# no integer (to cover the "no digit" branch), plus a leading non-Name line. -_SINKS_TEXT = """Sink #62 -\tState: SUSPENDED -\tName: alsa_output.analog -\tMute: no -\tVolume: front-left: 65536 / 100% -\tDescription: Built-in Analog -Sink #63 -\tName: alsa_output.weird -\tMute: yes -\tVolume: n/a -""" - - -class TestRestoreAudioState: - """Tests for _restore_audio_state.""" - - def test_none_is_noop(self) -> None: - """None state → does nothing, no pactl lookup.""" - with patch("wake_alarm._sinks._pactl_path") as mock_path: - _restore_audio_state(None) - mock_path.assert_not_called() - - def test_no_pactl_returns_silently(self) -> None: - """State present but pactl missing → no raise, no run.""" - state = _AudioState( - default_sink="s", - card_profiles={}, - sink_volumes={}, - sink_mutes={}, - ) - with ( - patch("wake_alarm._sinks._pactl_path", return_value=None), - patch("wake_alarm._sinks.subprocess.run") as mock_run, - ): - _restore_audio_state(state) - mock_run.assert_not_called() - - def test_restores_everything(self) -> None: - """Restores card profiles, sink volumes, mutes, and the default sink.""" - state = _AudioState( - default_sink="analog", - card_profiles={"card_hdmi": "off"}, - sink_volumes={"analog": "65536"}, - sink_mutes={"analog": True}, - ) - with ( - patch("wake_alarm._sinks._pactl_path", return_value="/usr/bin/pactl"), - patch("wake_alarm._sinks.subprocess.run") as mock_run, - ): - _restore_audio_state(state) - cmds = [call.args[0] for call in mock_run.call_args_list] - assert ["/usr/bin/pactl", "set-card-profile", "card_hdmi", "off"] in cmds - assert ["/usr/bin/pactl", "set-sink-volume", "analog", "65536"] in cmds - assert ["/usr/bin/pactl", "set-sink-mute", "analog", "1"] in cmds - assert ["/usr/bin/pactl", "set-default-sink", "analog"] in cmds - - def test_skips_default_when_none(self) -> None: - """default_sink None → no set-default-sink command.""" - state = _AudioState( - default_sink=None, - card_profiles={}, - sink_volumes={"analog": "100"}, - sink_mutes={"analog": False}, - ) - with ( - patch("wake_alarm._sinks._pactl_path", return_value="/usr/bin/pactl"), - patch("wake_alarm._sinks.subprocess.run") as mock_run, - ): - _restore_audio_state(state) - cmds = [call.args[0] for call in mock_run.call_args_list] - assert not any(c[1] == "set-default-sink" for c in cmds) - # Unmuted sink restores with "0". - assert ["/usr/bin/pactl", "set-sink-mute", "analog", "0"] in cmds diff --git a/wake_alarm/tests/test_sinks_warn.py b/wake_alarm/tests/test_sinks_warn.py deleted file mode 100644 index 6aab23e..0000000 --- a/wake_alarm/tests/test_sinks_warn.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Tests for the no-real-sink warning.""" - -from __future__ import annotations - -from unittest.mock import patch - -from wake_alarm._sinks import ( - _warn_if_no_real_sink, -) - -# A representative `pactl list cards` block covering every parse branch: -# an HDMI card (off, with an available stereo output profile), an input-only -# card (no output profile), a card block with no Name line (must be skipped). -_CARDS_TEXT = """Card #54 -\tName: alsa_card.hdmi -\tDriver: module-alsa-card.c -\tProfiles: -\t\toff: Off (sinks: 0, sources: 0, priority: 0, available: yes) -\t\toutput:hdmi-stereo: Stereo (sinks: 1, priority: 5900, available: yes) -\t\toutput:hdmi-surround: Surround (sinks: 1, priority: 600, available: no) -\t\tbroken (available: yes) -\tActive Profile: off -Card #55 -\tName: alsa_card.mic -\tProfiles: -\t\toff: Off (sinks: 0, sources: 0, priority: 0, available: yes) -\tActive Profile: off -Card #56 -\tProfiles: -\t\toutput:noname: NoName (available: yes) -\tActive Profile: off -""" - -# A `pactl list sinks` block: a normal sink, plus one whose Volume line has -# no integer (to cover the "no digit" branch), plus a leading non-Name line. -_SINKS_TEXT = """Sink #62 -\tState: SUSPENDED -\tName: alsa_output.analog -\tMute: no -\tVolume: front-left: 65536 / 100% -\tDescription: Built-in Analog -Sink #63 -\tName: alsa_output.weird -\tMute: yes -\tVolume: n/a -""" - - -class TestWarnIfNoRealSink: - """Tests for _warn_if_no_real_sink.""" - - def test_logs_when_pactl_missing(self) -> None: - """No pactl on PATH → warns and returns (no sink lookup).""" - with ( - patch("wake_alarm._sinks._pactl_path", return_value=None), - patch("wake_alarm._sinks._list_real_sinks") as mock_list, - ): - _warn_if_no_real_sink() - mock_list.assert_not_called() - - def test_warns_when_no_real_sink(self) -> None: - """No real sink → warning is emitted.""" - with ( - patch("wake_alarm._sinks._pactl_path", return_value="/usr/bin/pactl"), - patch("wake_alarm._sinks._list_real_sinks", return_value=[]), - patch("wake_alarm._sinks._logger") as mock_log, - ): - _warn_if_no_real_sink() - mock_log.warning.assert_called() - - def test_info_when_real_sink_present(self) -> None: - """A real sink → info log, no warning.""" - with ( - patch("wake_alarm._sinks._pactl_path", return_value="/usr/bin/pactl"), - patch( - "wake_alarm._sinks._list_real_sinks", - return_value=["alsa_output.analog"], - ), - patch("wake_alarm._sinks._logger") as mock_log, - ): - _warn_if_no_real_sink() - mock_log.info.assert_called() - mock_log.warning.assert_not_called() diff --git a/wake_alarm/tests/test_smart_plug.py b/wake_alarm/tests/test_smart_plug.py deleted file mode 100644 index 8abdd11..0000000 --- a/wake_alarm/tests/test_smart_plug.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Tests for _smart_plug.py — Tapo P110 control with config + asyncio.""" - -from __future__ import annotations - -import asyncio -import json -from typing import TYPE_CHECKING -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from wake_alarm import _smart_plug -from wake_alarm._smart_plug import ( - _connect, - _load_config, -) - -if TYPE_CHECKING: - from collections.abc import Generator - from pathlib import Path - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_config_file(tmp_path: Path, contents: object) -> Path: - """Write ``contents`` (encoded as JSON unless str) to a config file.""" - path = tmp_path / "tapo.json" - if isinstance(contents, str): - path.write_text(contents, encoding="utf-8") - else: - path.write_text(json.dumps(contents), encoding="utf-8") - return path - - -@pytest.fixture -def _kasa_available() -> Generator[None]: - """Force _smart_plug to treat ``kasa`` as importable for the test.""" - with patch.object(_smart_plug, "_KASA_AVAILABLE", new=True): - yield - - -# --------------------------------------------------------------------------- -# _load_config -# --------------------------------------------------------------------------- - - -class TestLoadConfig: - """Tests for _load_config().""" - - def test_returns_none_when_file_missing(self, tmp_path: Path) -> None: - """Missing config file returns None.""" - with patch.object(_smart_plug, "TAPO_CONFIG_FILE", tmp_path / "missing.json"): - assert _load_config() is None - - def test_returns_none_for_invalid_json(self, tmp_path: Path) -> None: - """Malformed JSON returns None.""" - path = _make_config_file(tmp_path, "{not valid json") - with patch.object(_smart_plug, "TAPO_CONFIG_FILE", path): - assert _load_config() is None - - def test_returns_none_when_top_level_not_dict(self, tmp_path: Path) -> None: - """A JSON list at top level returns None.""" - path = _make_config_file(tmp_path, ["host", "email", "password"]) - with patch.object(_smart_plug, "TAPO_CONFIG_FILE", path): - assert _load_config() is None - - def test_returns_none_when_key_missing(self, tmp_path: Path) -> None: - """Missing required key returns None.""" - path = _make_config_file(tmp_path, {"host": "1.2.3.4", "email": "x"}) - with patch.object(_smart_plug, "TAPO_CONFIG_FILE", path): - assert _load_config() is None - - def test_returns_none_when_value_empty(self, tmp_path: Path) -> None: - """Empty-string value returns None.""" - path = _make_config_file( - tmp_path, {"host": "1.2.3.4", "email": "", "password": "p"} - ) - with patch.object(_smart_plug, "TAPO_CONFIG_FILE", path): - assert _load_config() is None - - def test_returns_none_when_value_not_string(self, tmp_path: Path) -> None: - """Non-string value returns None.""" - path = _make_config_file( - tmp_path, {"host": 1234, "email": "e", "password": "p"} - ) - with patch.object(_smart_plug, "TAPO_CONFIG_FILE", path): - assert _load_config() is None - - def test_returns_validated_dict(self, tmp_path: Path) -> None: - """Valid config returns a normalized dict with only required keys.""" - path = _make_config_file( - tmp_path, - { - "host": "192.168.1.50", - "email": "user@example.com", - "password": "secret", - "extra": "ignored", - }, - ) - with patch.object(_smart_plug, "TAPO_CONFIG_FILE", path): - assert _load_config() == { - "host": "192.168.1.50", - "email": "user@example.com", - "password": "secret", - } - - -# --------------------------------------------------------------------------- -# _connect -# --------------------------------------------------------------------------- - - -class TestConnect: - """Tests for _connect().""" - - def test_returns_device_on_success(self) -> None: - """Successful discover + update returns the device.""" - dev = MagicMock() - dev.update = AsyncMock() - dev.disconnect = AsyncMock() - with ( - patch.object(_smart_plug, "Discover") as mock_discover, - patch.object(_smart_plug, "Credentials") as mock_creds, - ): - mock_discover.discover_single = AsyncMock(return_value=dev) - result = asyncio.run( - _connect({"host": "1.2.3.4", "email": "e", "password": "p"}) - ) - assert result is dev - mock_creds.assert_called_once_with("e", "p") - - def test_returns_none_when_discover_raises_oserror(self) -> None: - """OSError during discovery returns None.""" - with patch.object(_smart_plug, "Discover") as mock_discover: - mock_discover.discover_single = AsyncMock(side_effect=OSError) - result = asyncio.run(_connect({"host": "h", "email": "e", "password": "p"})) - assert result is None - - def test_returns_none_when_update_raises(self) -> None: - """Failure during update returns None and attempts disconnect.""" - dev = MagicMock() - dev.update = AsyncMock(side_effect=OSError) - dev.disconnect = AsyncMock() - with patch.object(_smart_plug, "Discover") as mock_discover: - mock_discover.discover_single = AsyncMock(return_value=dev) - result = asyncio.run(_connect({"host": "h", "email": "e", "password": "p"})) - assert result is None - dev.disconnect.assert_awaited_once() - - def test_swallows_disconnect_failure_after_update_error(self) -> None: - """A disconnect error after a failed update is suppressed.""" - dev = MagicMock() - dev.update = AsyncMock(side_effect=OSError) - dev.disconnect = AsyncMock(side_effect=OSError) - with patch.object(_smart_plug, "Discover") as mock_discover: - mock_discover.discover_single = AsyncMock(return_value=dev) - result = asyncio.run(_connect({"host": "h", "email": "e", "password": "p"})) - assert result is None - - -# --------------------------------------------------------------------------- -# _set_state -# --------------------------------------------------------------------------- diff --git a/wake_alarm/tests/test_smart_plug_ops.py b/wake_alarm/tests/test_smart_plug_ops.py deleted file mode 100644 index 2bfed9e..0000000 --- a/wake_alarm/tests/test_smart_plug_ops.py +++ /dev/null @@ -1,234 +0,0 @@ -"""Tests for _smart_plug.py's device operations — set_state, run, and the -kasa import fallback. - -Split out of test_smart_plug.py for the 250-line gate; that file keeps -config loading and connection.""" - -from __future__ import annotations - -import asyncio -import json -from typing import TYPE_CHECKING -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from wake_alarm import _smart_plug -from wake_alarm._smart_plug import ( - _run, - _set_state, - turn_off_plug, - turn_on_plug, -) - -if TYPE_CHECKING: - from collections.abc import Generator - from pathlib import Path - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_config_file(tmp_path: Path, contents: object) -> Path: - """Write ``contents`` (encoded as JSON unless str) to a config file.""" - path = tmp_path / "tapo.json" - if isinstance(contents, str): - path.write_text(contents, encoding="utf-8") - else: - path.write_text(json.dumps(contents), encoding="utf-8") - return path - - -@pytest.fixture -def _kasa_available() -> Generator[None]: - """Force _smart_plug to treat ``kasa`` as importable for the test.""" - with patch.object(_smart_plug, "_KASA_AVAILABLE", new=True): - yield - - -# --------------------------------------------------------------------------- -# _load_config -# --------------------------------------------------------------------------- - - -class TestSetState: - """Tests for _set_state().""" - - def test_noop_when_config_missing(self) -> None: - """No config => no kasa calls.""" - with ( - patch.object(_smart_plug, "_load_config", return_value=None), - patch.object(_smart_plug, "_connect") as mock_connect, - ): - asyncio.run(_set_state(on=True)) - mock_connect.assert_not_called() - - def test_noop_when_connect_returns_none(self) -> None: - """Connect failure => no toggle.""" - with ( - patch.object( - _smart_plug, - "_load_config", - return_value={"host": "h", "email": "e", "password": "p"}, - ), - patch.object(_smart_plug, "_connect", new=AsyncMock(return_value=None)), - ): - asyncio.run(_set_state(on=True)) - - def test_turns_on_when_on_true(self) -> None: - """on=True calls dev.turn_on(), not turn_off().""" - dev = MagicMock() - dev.turn_on = AsyncMock() - dev.turn_off = AsyncMock() - dev.disconnect = AsyncMock() - with ( - patch.object( - _smart_plug, - "_load_config", - return_value={"host": "h", "email": "e", "password": "p"}, - ), - patch.object(_smart_plug, "_connect", new=AsyncMock(return_value=dev)), - ): - asyncio.run(_set_state(on=True)) - dev.turn_on.assert_awaited_once() - dev.turn_off.assert_not_called() - dev.disconnect.assert_awaited_once() - - def test_turns_off_when_on_false(self) -> None: - """on=False calls dev.turn_off(), not turn_on().""" - dev = MagicMock() - dev.turn_on = AsyncMock() - dev.turn_off = AsyncMock() - dev.disconnect = AsyncMock() - with ( - patch.object( - _smart_plug, - "_load_config", - return_value={"host": "h", "email": "e", "password": "p"}, - ), - patch.object(_smart_plug, "_connect", new=AsyncMock(return_value=dev)), - ): - asyncio.run(_set_state(on=False)) - dev.turn_off.assert_awaited_once() - dev.turn_on.assert_not_called() - - def test_swallows_toggle_oserror_and_still_disconnects(self) -> None: - """A toggle OSError is swallowed; disconnect still runs.""" - dev = MagicMock() - dev.turn_on = AsyncMock(side_effect=OSError) - dev.disconnect = AsyncMock() - with ( - patch.object( - _smart_plug, - "_load_config", - return_value={"host": "h", "email": "e", "password": "p"}, - ), - patch.object(_smart_plug, "_connect", new=AsyncMock(return_value=dev)), - ): - asyncio.run(_set_state(on=True)) - dev.disconnect.assert_awaited_once() - - def test_swallows_disconnect_oserror(self) -> None: - """A disconnect OSError after a successful toggle is suppressed.""" - dev = MagicMock() - dev.turn_on = AsyncMock() - dev.disconnect = AsyncMock(side_effect=OSError) - with ( - patch.object( - _smart_plug, - "_load_config", - return_value={"host": "h", "email": "e", "password": "p"}, - ), - patch.object(_smart_plug, "_connect", new=AsyncMock(return_value=dev)), - ): - asyncio.run(_set_state(on=True)) - - -# --------------------------------------------------------------------------- -# _run, turn_on_plug, turn_off_plug -# --------------------------------------------------------------------------- - - -class TestRun: - """Tests for _run() and the sync wrappers.""" - - def test_noop_when_kasa_unavailable(self) -> None: - """When kasa import failed, _run returns silently.""" - with ( - patch.object(_smart_plug, "_KASA_AVAILABLE", new=False), - patch.object(_smart_plug, "_set_state") as mock_set_state, - ): - _run(on=True) - mock_set_state.assert_not_called() - - @pytest.mark.usefixtures("_kasa_available") - def test_invokes_set_state(self) -> None: - """When kasa is available, _set_state runs via asyncio.run.""" - with patch.object(_smart_plug, "_set_state", new=AsyncMock()) as mock_set_state: - _run(on=True) - mock_set_state.assert_awaited_once_with(on=True) - - @pytest.mark.usefixtures("_kasa_available") - def test_swallows_timeout(self) -> None: - """A timeout from asyncio.wait_for is suppressed.""" - - async def _hang(**_: bool) -> None: - await asyncio.sleep(10) - - with ( - patch.object(_smart_plug, "_set_state", new=_hang), - patch.object(_smart_plug, "TAPO_TIMEOUT_SECONDS", 0.01), - ): - _run(on=True) - - @pytest.mark.usefixtures("_kasa_available") - def test_swallows_oserror(self) -> None: - """An OSError raised from _set_state is suppressed.""" - with patch.object( - _smart_plug, "_set_state", new=AsyncMock(side_effect=OSError) - ): - _run(on=True) - - @pytest.mark.usefixtures("_kasa_available") - def test_swallows_runtimeerror(self) -> None: - """A RuntimeError raised from _set_state is suppressed.""" - with patch.object( - _smart_plug, "_set_state", new=AsyncMock(side_effect=RuntimeError) - ): - _run(on=True) - - @pytest.mark.usefixtures("_kasa_available") - def test_turn_on_plug_delegates(self) -> None: - """turn_on_plug calls _run with on=True.""" - with patch.object(_smart_plug, "_run") as mock_run: - turn_on_plug() - mock_run.assert_called_once_with(on=True) - - @pytest.mark.usefixtures("_kasa_available") - def test_turn_off_plug_delegates(self) -> None: - """turn_off_plug calls _run with on=False.""" - with patch.object(_smart_plug, "_run") as mock_run: - turn_off_plug() - mock_run.assert_called_once_with(on=False) - - -class TestKasaImportFallback: - """Cover the ImportError branch of the optional ``kasa`` import.""" - - def test_module_sets_kasa_unavailable_when_import_fails( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Reloading the module with ``kasa`` blocked sets _KASA_AVAILABLE=False.""" - import importlib - import sys - - monkeypatch.setitem(sys.modules, "kasa", None) - monkeypatch.setitem(sys.modules, "kasa.exceptions", None) - try: - reloaded = importlib.reload(_smart_plug) - assert reloaded._KASA_AVAILABLE is False - finally: - monkeypatch.undo() - importlib.reload(_smart_plug) diff --git a/wake_alarm/tests/test_state.py b/wake_alarm/tests/test_state.py index ff7eeea..3e3d44c 100644 --- a/wake_alarm/tests/test_state.py +++ b/wake_alarm/tests/test_state.py @@ -11,7 +11,7 @@ import pytest from wake_alarm._state import ( _today_str, load_wake_state, - save_wake_state, + save_trigger_fired, ) if TYPE_CHECKING: @@ -46,7 +46,7 @@ class TestTodayStr: class TestSaveWakeState: - """Tests for save_wake_state.""" + """Tests for save_trigger_fired.""" def test_saves_with_hmac(self, wake_state_file: Path) -> None: """Save state with HMAC signature when key is available.""" @@ -54,15 +54,11 @@ class TestSaveWakeState: "wake_alarm._state.compute_entry_hmac", return_value="fakesig", ): - result = save_wake_state( - dismissed_at="2026-04-12T07:04:00+00:00", - skip_workout=True, - ) + result = save_trigger_fired(fired_at="2026-04-12T07:04:00+00:00") assert result is True data = json.loads(wake_state_file.read_text()) - assert data["skip_workout"] is True - assert data["dismissed_at"] == "2026-04-12T07:04:00+00:00" + assert data["fired_at"] == "2026-04-12T07:04:00+00:00" assert data["hmac"] == "fakesig" assert data["date"] == _today_str() @@ -72,14 +68,10 @@ class TestSaveWakeState: "wake_alarm._state.compute_entry_hmac", return_value=None, ): - result = save_wake_state( - dismissed_at=None, - skip_workout=False, - ) + result = save_trigger_fired(fired_at="07:04") assert result is True data = json.loads(wake_state_file.read_text()) - assert data["skip_workout"] is False assert "hmac" not in data def test_returns_false_on_write_error(self, wake_state_file: Path) -> None: @@ -94,7 +86,7 @@ class TestSaveWakeState: wake_state_file / "nonexistent_dir" / "file.json", ), ): - result = save_wake_state(dismissed_at=None, skip_workout=False) + result = save_trigger_fired(fired_at="07:04") assert result is False @@ -111,7 +103,7 @@ class TestLoadWakeState: wake_state_file: Path, ) -> None: """Return None when state is from a different day.""" - state = {"date": "1999-01-01", "skip_workout": True, "hmac": "x"} + state = {"date": "1999-01-01", "hmac": "x"} wake_state_file.write_text(json.dumps(state)) assert load_wake_state() is None @@ -138,8 +130,7 @@ class TestLoadWakeState: """Return None when HMAC verification fails.""" state = { "date": _today_str(), - "skip_workout": True, - "dismissed_at": "07:00", + "fired_at": "07:00", "hmac": "badsig", } wake_state_file.write_text(json.dumps(state)) @@ -156,8 +147,7 @@ class TestLoadWakeState: """Return state dict when file is valid and for today.""" state = { "date": _today_str(), - "skip_workout": True, - "dismissed_at": "07:04", + "fired_at": "07:04", "hmac": "validsig", } wake_state_file.write_text(json.dumps(state)) @@ -168,4 +158,3 @@ class TestLoadWakeState: result = load_wake_state() assert result is not None - assert result["skip_workout"] is True diff --git a/wake_alarm/tests/test_state_queries.py b/wake_alarm/tests/test_state_queries.py index 3002f2d..7b77f3c 100644 --- a/wake_alarm/tests/test_state_queries.py +++ b/wake_alarm/tests/test_state_queries.py @@ -14,9 +14,7 @@ import pytest from wake_alarm._state import ( _today_str, - has_workout_skip_today, - was_alarm_dismissed_today, - was_workout_logged_today, + was_trigger_fired_today, ) if TYPE_CHECKING: @@ -39,22 +37,21 @@ def _patch_wake_state_file(wake_state_file: Path) -> None: yield -class TestHasWorkoutSkipToday: - """Tests for has_workout_skip_today.""" +class TestWasTriggerFiredToday: + """Tests for the trigger's once-per-day latch.""" def test_returns_false_when_no_state(self) -> None: """Return False when no state file exists.""" - assert has_workout_skip_today() is False + assert was_trigger_fired_today() is False - def test_returns_true_when_skip_granted( + def test_returns_true_once_fired( self, wake_state_file: Path, ) -> None: - """Return True when today's state has skip_workout=True.""" + """Return True when the routine already started today.""" state = { "date": _today_str(), - "skip_workout": True, - "dismissed_at": "07:04", + "fired_at": "07:04", "hmac": "sig", } wake_state_file.write_text(json.dumps(state)) @@ -62,17 +59,16 @@ class TestHasWorkoutSkipToday: "wake_alarm._state.verify_entry_hmac", return_value=True, ): - assert has_workout_skip_today() is True + assert was_trigger_fired_today() is True - def test_returns_false_when_skip_not_granted( + def test_returns_false_before_firing( self, wake_state_file: Path, ) -> None: - """Return False when today's state has skip_workout=False.""" + """Return False when it has not started yet.""" state = { "date": _today_str(), - "skip_workout": False, - "dismissed_at": None, + "fired_at": None, "hmac": "sig", } wake_state_file.write_text(json.dumps(state)) @@ -80,100 +76,4 @@ class TestHasWorkoutSkipToday: "wake_alarm._state.verify_entry_hmac", return_value=True, ): - assert has_workout_skip_today() is False - - -class TestWasAlarmDismissedToday: - """Tests for was_alarm_dismissed_today.""" - - def test_returns_false_when_no_state(self) -> None: - """Return False when no state file exists.""" - assert was_alarm_dismissed_today() is False - - def test_returns_true_when_dismissed( - self, - wake_state_file: Path, - ) -> None: - """Return True when alarm was dismissed today.""" - state = { - "date": _today_str(), - "dismissed_at": "07:04", - "skip_workout": True, - "hmac": "sig", - } - wake_state_file.write_text(json.dumps(state)) - with patch( - "wake_alarm._state.verify_entry_hmac", - return_value=True, - ): - assert was_alarm_dismissed_today() is True - - def test_returns_false_when_not_dismissed( - self, - wake_state_file: Path, - ) -> None: - """Return False when alarm was not dismissed.""" - state = { - "date": _today_str(), - "dismissed_at": None, - "skip_workout": False, - "hmac": "sig", - } - wake_state_file.write_text(json.dumps(state)) - with patch( - "wake_alarm._state.verify_entry_hmac", - return_value=True, - ): - assert was_alarm_dismissed_today() is False - - -class TestWasWorkoutLoggedToday: - """Tests for was_workout_logged_today.""" - - def test_returns_false_when_file_missing(self, tmp_path: Path) -> None: - """Return False when the workout log file does not exist.""" - with patch( - "wake_alarm._state.WORKOUT_LOG_FILE", - tmp_path / "workout_log.json", - ): - assert was_workout_logged_today() is False - - def test_returns_false_when_file_is_invalid_json(self, tmp_path: Path) -> None: - """Return False when the workout log contains invalid JSON.""" - log_file = tmp_path / "workout_log.json" - log_file.write_text("not json {{{") - with patch( - "wake_alarm._state.WORKOUT_LOG_FILE", - log_file, - ): - assert was_workout_logged_today() is False - - def test_returns_false_when_file_is_not_a_dict(self, tmp_path: Path) -> None: - """Return False when the workout log is not a JSON object.""" - log_file = tmp_path / "workout_log.json" - log_file.write_text(json.dumps([1, 2, 3])) - with patch( - "wake_alarm._state.WORKOUT_LOG_FILE", - log_file, - ): - assert was_workout_logged_today() is False - - def test_returns_false_when_today_absent(self, tmp_path: Path) -> None: - """Return False when the workout log has no entry for today.""" - log_file = tmp_path / "workout_log.json" - log_file.write_text(json.dumps({"1999-01-01": {"type": "old"}})) - with patch( - "wake_alarm._state.WORKOUT_LOG_FILE", - log_file, - ): - assert was_workout_logged_today() is False - - def test_returns_true_when_today_present(self, tmp_path: Path) -> None: - """Return True when today's date key exists in the workout log.""" - log_file = tmp_path / "workout_log.json" - log_file.write_text(json.dumps({_today_str(): {"type": "phone_verified"}})) - with patch( - "wake_alarm._state.WORKOUT_LOG_FILE", - log_file, - ): - assert was_workout_logged_today() is True + assert was_trigger_fired_today() is False diff --git a/wake_alarm/tests/test_tone.py b/wake_alarm/tests/test_tone.py deleted file mode 100644 index 061e6ff..0000000 --- a/wake_alarm/tests/test_tone.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Tests for alarm tone synthesis and caching.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import patch - -if TYPE_CHECKING: - import pathlib - -from wake_alarm._tone import _ensure_tone_wav - - -class TestEnsureToneWav: - """Tests for _ensure_tone_wav (sine WAV generator + cache).""" - - def test_generates_and_caches(self, tmp_path: pathlib.Path) -> None: - """First call generates the WAV; second call returns the cached path.""" - from wake_alarm import _tone as tone_mod - - tone_mod._TONE_CACHE.clear() - with patch( - "wake_alarm._tone.tempfile.gettempdir", - return_value=str(tmp_path), - ): - path1 = _ensure_tone_wav(440) - assert path1.exists() - size = path1.stat().st_size - assert size > 0 - # Second call must hit the cache (no regeneration). - with patch("wake_alarm._tone.wave.open") as mock_open: - path2 = _ensure_tone_wav(440) - mock_open.assert_not_called() - assert path2 == path1 - tone_mod._TONE_CACHE.clear() - - def test_regenerates_when_cached_file_missing( - self, - tmp_path: pathlib.Path, - ) -> None: - """If the cached file was deleted, regenerate it.""" - from wake_alarm._tone import _TONE_CACHE - - _TONE_CACHE.clear() - with patch( - "wake_alarm._tone.tempfile.gettempdir", - return_value=str(tmp_path), - ): - path1 = _ensure_tone_wav(880) - path1.unlink() - path2 = _ensure_tone_wav(880) - assert path2.exists() - _TONE_CACHE.clear() diff --git a/wake_alarm/tests/test_trigger.py b/wake_alarm/tests/test_trigger.py index 7dca6e9..a6b931d 100644 --- a/wake_alarm/tests/test_trigger.py +++ b/wake_alarm/tests/test_trigger.py @@ -3,15 +3,10 @@ from __future__ import annotations from datetime import UTC, datetime -from typing import TYPE_CHECKING from unittest.mock import patch import pytest -if TYPE_CHECKING: - from pathlib import Path - -from wake_alarm import _alarm_session from wake_alarm._sync import Alarm from wake_alarm._trigger import ( _decide, @@ -35,14 +30,12 @@ def _at(hour: int, minute: int) -> datetime: def _ungated() -> None: """Open every gate unless a test closes one. - ``should_refire`` is stubbed here too: unstubbed it reads the *real* + The once-per-day latch is stubbed: unstubbed it reads the *real* session record on the host, so a test run started while an alarm was actually ringing would silently invert half these assertions. """ with ( - patch("wake_alarm._trigger.was_alarm_dismissed_today", return_value=False), - patch("wake_alarm._trigger.was_workout_logged_today", return_value=False), - patch("wake_alarm._trigger.should_refire", return_value=False), + patch("wake_alarm._trigger.was_trigger_fired_today", return_value=False), ): yield @@ -102,18 +95,13 @@ class TestSkipReason: with patch("wake_alarm._trigger.ALARM_DAYS", frozenset()): assert _skip_reason(_at(7, 0)) == "not an alarm day" - def test_already_dismissed(self) -> None: - """A dismissal recorded today blocks a re-fire.""" + def test_already_fired_today(self) -> None: + """The once-per-day latch blocks every later tick in the window.""" with patch( - "wake_alarm._trigger.was_alarm_dismissed_today", + "wake_alarm._trigger.was_trigger_fired_today", return_value=True, ): - assert _skip_reason(_at(7, 0)) == "alarm already dismissed today" - - def test_workout_already_logged(self) -> None: - """A logged workout means the alarm is no longer needed.""" - with patch("wake_alarm._trigger.was_workout_logged_today", return_value=True): - assert _skip_reason(_at(7, 0)) == "workout already logged today" + assert _skip_reason(_at(7, 0)) == "already fired today" class TestDecide: @@ -150,82 +138,14 @@ class TestDecide: def test_gate_beats_the_clock(self) -> None: """A closed gate wins even at the exact alarm minute.""" with patch( - "wake_alarm._trigger.was_alarm_dismissed_today", + "wake_alarm._trigger.was_trigger_fired_today", return_value=True, ): fire, reason = _decide(_at(6, 45), Alarm(6, 45, "remote")) assert not fire - assert reason == "alarm already dismissed today" + assert reason == "already fired today" def test_alarm_moved_earlier_fires_immediately(self) -> None: """A phone alarm moved back before now fires on the very next tick.""" fire, _ = _decide(_at(6, 50), Alarm(6, 40, "remote")) assert fire - - -class TestRefireOnAnUnfinishedSession: - """A power-off mid-alarm must not be a way out.""" - - def test_refires_long_after_the_window_closed(self) -> None: - """The whole point: the machine may have been off for the window. - - Without this, holding the power button ends the alarm for good -- - the catch-up window closes while the PC is down and nothing on disk - ever said an alarm was ringing. - """ - with patch("wake_alarm._trigger.should_refire", return_value=True): - fire, reason = _decide(_at(20, 0), Alarm(6, 45, "remote")) - assert fire - assert "cut short" in reason - - def test_dismissed_gate_still_beats_a_refire(self) -> None: - """The ordinary gates are checked first and still win.""" - with ( - patch("wake_alarm._trigger.should_refire", return_value=True), - patch( - "wake_alarm._trigger.was_alarm_dismissed_today", - return_value=True, - ), - ): - fire, reason = _decide(_at(20, 0), Alarm(6, 45, "remote")) - assert not fire - assert reason == "alarm already dismissed today" - - def test_end_to_end_through_a_real_session_record( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """_decide against a real record, with should_refire NOT patched. - - Every other test here stubs ``should_refire``, so the wiring between - the record on disk and the decision was untested -- and a manual run - found it broken while the whole suite stayed green. This is the - deterministic version of that manual check. - """ - monkeypatch.setattr( - _alarm_session, "ALARM_SESSION_FILE", tmp_path / "session.json" - ) - now = _at(20, 0) - # The record is stamped by the alarm process, which ran at fire time -- - # not at this tick. Pin its clock to the same fictional morning as the - # tick, or the two dates are years apart and the age check is - # meaningless. - monkeypatch.setattr(_alarm_session, "_now", lambda: _at(19, 30)) - alarm = Alarm(6, 45, "remote") - - with patch("wake_alarm._trigger.should_refire", _alarm_session.should_refire): - assert _decide(now, alarm)[0] is False # window closed, no record - - _alarm_session.start_session("wake-20260719-0645") - fire, reason = _decide(now, alarm) - assert fire, "a cut-short alarm must ring again" - assert "cut short" in reason - - _alarm_session.finish_session() - assert _decide(now, alarm)[0] is False # dismissed stays dismissed - - def test_refire_is_asked_about_the_tick_instant(self) -> None: - """The session's age is measured against the tick, not wall-clock now.""" - with patch("wake_alarm._trigger.should_refire") as refire: - refire.return_value = False - _decide(_at(20, 0), Alarm(6, 45, "remote")) - refire.assert_called_once_with(now=_at(20, 0)) diff --git a/wake_alarm/tests/test_trigger_clamp.py b/wake_alarm/tests/test_trigger_clamp.py index 2217fd7..e29e863 100644 --- a/wake_alarm/tests/test_trigger_clamp.py +++ b/wake_alarm/tests/test_trigger_clamp.py @@ -28,14 +28,12 @@ def _at(hour: int, minute: int) -> datetime: def _ungated() -> None: """Open every gate unless a test closes one. - ``should_refire`` is stubbed here too: unstubbed it reads the *real* + The once-per-day latch is stubbed: unstubbed it reads the *real* session record on the host, so a test run started while an alarm was actually ringing would silently invert half these assertions. """ with ( - patch("wake_alarm._trigger.was_alarm_dismissed_today", return_value=False), - patch("wake_alarm._trigger.was_workout_logged_today", return_value=False), - patch("wake_alarm._trigger.should_refire", return_value=False), + patch("wake_alarm._trigger.was_trigger_fired_today", return_value=False), ): yield diff --git a/wake_alarm/tests/test_trigger_main.py b/wake_alarm/tests/test_trigger_main.py index e575473..2fbe967 100644 --- a/wake_alarm/tests/test_trigger_main.py +++ b/wake_alarm/tests/test_trigger_main.py @@ -34,14 +34,12 @@ def _at(hour: int, minute: int) -> datetime: def _ungated() -> None: """Open every gate unless a test closes one. - ``should_refire`` is stubbed here too: unstubbed it reads the *real* + The once-per-day latch is stubbed: unstubbed it reads the *real* session record on the host, so a test run started while an alarm was actually ringing would silently invert half these assertions. """ with ( - patch("wake_alarm._trigger.was_alarm_dismissed_today", return_value=False), - patch("wake_alarm._trigger.was_workout_logged_today", return_value=False), - patch("wake_alarm._trigger.should_refire", return_value=False), + patch("wake_alarm._trigger.was_trigger_fired_today", return_value=False), ): yield @@ -97,6 +95,43 @@ class TestMain: main() return start + def test_a_successful_start_latches_the_day(self) -> None: + """Firing records the latch, so later ticks in the window skip. + + Without this the routine is restarted on every tick for the rest of + ``TRIGGER_CATCHUP_MINUTES``: the unit is Type=simple, so a start while + it runs is a no-op, but the moment the workout lock finishes the next + tick relaunches it on top of the user. + """ + with ( + patch("sys.argv", ["_trigger"]), + patch("wake_alarm._trigger._now", return_value=_at(6, 45)), + patch( + "wake_alarm._trigger.get_alarm", + return_value=Alarm(6, 45, "remote"), + ), + patch("wake_alarm._trigger._start_target_unit", return_value=True), + patch("wake_alarm._trigger.save_trigger_fired") as saved, + ): + main() + assert saved.call_count == 1 + assert saved.call_args.kwargs["fired_at"] == _at(6, 45).isoformat() + + def test_a_failed_start_does_not_latch(self) -> None: + """A start that failed must stay retryable on the next tick.""" + with ( + patch("sys.argv", ["_trigger"]), + patch("wake_alarm._trigger._now", return_value=_at(6, 45)), + patch( + "wake_alarm._trigger.get_alarm", + return_value=Alarm(6, 45, "remote"), + ), + patch("wake_alarm._trigger._start_target_unit", return_value=False), + patch("wake_alarm._trigger.save_trigger_fired") as saved, + ): + main() + saved.assert_not_called() + def test_fires_at_the_alarm_minute(self) -> None: """On the alarm minute the target unit is started.""" start = self._run_main([], _at(6, 45), Alarm(6, 45, "remote"))