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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
This commit is contained in:
Krzysztof Rudnicki 2026-08-30 15:24:02 +02:00
parent e83ff97f47
commit e67cef0c54
93 changed files with 492 additions and 11106 deletions

View File

@ -95,7 +95,6 @@ repos:
- --jobs=4 - --jobs=4
additional_dependencies: additional_dependencies:
- pytest - pytest
- python-kasa
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.1#subdirectory=gatelock - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.1#subdirectory=gatelock
# Test suites intentionally use patterns (protected-access, magic-value # Test suites intentionally use patterns (protected-access, magic-value
# comparisons) that don't belong in the source-code 10/10 gate. # 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' -- entry: bash -c 'printf "%s\0" "$@" | xargs -0 -n 40 shellcheck --severity=warning' --
language: system language: system
types: [shell] 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 # 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 # higher limit under this same hook id, so it looked gated while letting
# through files every other repo rejects. # through files every other repo rejects.

345
AGENTS.md
View File

@ -2,249 +2,208 @@
## What this does ## What this does
A wake alarm: on alarm days (`ALARM_DAYS` in `_constants.py` — currently **The phone is the alarm.** `phone_app/` arms and rings it and gates the
**every day**, `frozenset({0..6})`), the machine hibernates overnight and dismissal; this repo's Python half only reads the alarm time the phone
wakes itself via `rtcwake` at the configured alarm time. `wake-alarm.service` published and starts the morning routine at that time.
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`.
## 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 ## The dismiss sequence (phone)
it at any hour, so nothing about the schedule may be baked in ahead of time.
**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 Owned by `phone_app/lib/services/dismiss_gate.dart`. **Strict order, and every
runs `python -m wake_alarm._trigger` — a short, stateless check-and-fire. stage latches** — an unknown or out-of-order tag is ignored, never a reset.
2. `_trigger` resolves the alarm via `_sync.get_alarm()`, builds today's fire `room_proof.dart` answers only "did this one check pass just now"; the
instant in **local** time, and fires iff `now` is in ordering lives in the gate. Keep it that way: the previous version smeared the
`[fire_time, fire_time + TRIGGER_CATCHUP_MINUTES)`. The catch-up window is decision across `setState` calls and had a real bug where a wrong tap after
what makes a suspended or just-booted machine still fire. passing sent the user back to the start.
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.
A timer, not a resident daemon: each tick is a fresh process, so there is no - **Two *distinct* tags.** The same id in both slots is refused at
long-lived scheduler to die silently and no wall-clock jump to handle across registration — one tag satisfying both stages removes the walk, which is the
suspend/resume. 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 More, including everything that is a hard crash or a silent no-ring:
`/usr/bin/shutdown` via PATH order) still intercepts shutdown/poweroff, reads **`DOCS-phone-alarm.md`**.
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.
If you change `WAKE_AFTER_HOURS` in `_constants.py`, you must also update the ## Scheduling — a per-minute timer
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`).
**The wrapper no longer talks to the network or handles a token.** It reads `wake-alarm-trigger.timer` ticks every minute (`OnCalendar=*:*:00`,
`~/.config/wake_alarm/alarm_cache.json` — the cache `_sync.py` already `Persistent=true`) and runs `python -m wake_alarm._trigger` — a short,
refreshes every minute — and falls back to `WAKE_AFTER_HOURS` if that file is stateless check-and-fire. It resolves the alarm via `_sync.get_alarm()`, builds
absent, malformed, or more than 2 days old. This removed the duplicated token today's fire instant in **local** time, and fires iff `now` is in
search order (`read_sync_token()` used to mirror `SYNC_CONFIG_FILE` / `[fire_time, fire_time + TRIGGER_CATCHUP_MINUTES)`. Firing means
`SYNC_TOKEN_FALLBACK_FILES`), and it is what makes the wrapper survive the `systemctl --user start morning-routine.service`, which runs the workout lock.
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.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 `Persistent=true` plus the 60-minute window is also what replaced the deleted
starts it automatically. `_should_run_alarm()` gates on day / dismissed / systemd-sleep hook: a suspended or just-booted machine still fires.
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.
## Reading the phone's alarm ## Reading the phone's alarm
`_sync.py` is the only reader of the remote alarm. `shutdown-wrapper.sh` reads `_sync.py` is the only reader.
`_sync.py`'s local cache instead of the remote file, so there is no second
remote reader to keep in step.
- Source of truth: a `crdt_sync` log written by `phone_app/` under - Source of truth: a `crdt_sync` log written by `phone_app/` under
`wake-alarm-sync/devices/<device-id>/alarm.json`. The value lives at `wake-alarm-sync/devices/<device-id>/alarm.json`, at
`.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`. **The same logical path `.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`. Same logical path on both
on both backends** — Firebase's namespace mirrors the repo layout — so only backends, so only the transport differs, never the parse.
the transport differs, never the parse.
- **The Firebase reader discovers device directories; it is not pinned to - **The Firebase reader discovers device directories; it is not pinned to
`devices/phone/`.** The phone's device id is migrating from the fixed `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 constant `phone` to a per-install uuid, and a pinned reader would silently
would silently stop seeing the alarm the moment the phone migrated — then stop seeing the alarm and fall back to `FALLBACK_ALARM_HOUR` — i.e. the wrong
fall back to `FALLBACK_ALARM_HOUR`, i.e. wake at the wrong time. So time. `_fetch_firebase_alarm()` lists `SYNC_DEVICES_PREFIX`, reads every
`_fetch_firebase_alarm()` lists `SYNC_DEVICES_PREFIX`, reads every device's device's `alarm.json`, and picks the newest by the hour field's **HLC**, with
`alarm.json`, and picks the newest by the hour field's **HLC** (not by `device_id` breaking exact ties. A payload that does not parse is skipped
listing order), with `device_id` breaking exact ties so two devices stamped rather than allowed to win. `SYNC_ALARM_PATH` remains as a last-resort read
in the same millisecond don't alternate between ticks. A device whose for a backend that lists nothing.
payload doesn't parse is skipped rather than allowed to win. - Payload parsing/ranking lives in `_alarm_payload.py`: `_sync.py` owns
`SYNC_ALARM_PATH` remains as a last-resort read for a backend that lists fetching, caching and fallback; `_alarm_payload.py` owns "what did the phone
nothing — keep it until every install has migrated. The **GitHub** fallback actually say".
still reads the single fixed path; it is the mirror, not the primary. - **Firebase first, GitHub as the cutover mirror.** Retiring the mirror is
- Payload parsing/ranking lives in `_alarm_payload.py`, split out of `_sync.py` deleting the GitHub branch plus `SYNC_API_BASE`/`SYNC_REPO_*`.
so the wire format is testable without network or disk (and to keep both - Firebase config: shared `~/.config/crdt-sync/firebase.json`; the refresh
files under the 500-line cap). `_sync.py` owns fetching, caching and token is cached per app under `SYNC_APP_NAME` (`wake_alarm`). The config file
fallback; `_alarm_payload.py` owns "what did the phone actually say". is checked **before** anything is constructed, so an unconfigured machine
- **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
never touches the network. never touches the network.
- GitHub token (fallback path only): `~/.config/wake_alarm/sync.json` key - GitHub token (fallback only): `~/.config/wake_alarm/sync.json` key `token`,
`token`, else a sibling app's raw else a sibling app's raw `~/.config/{diet_guard,screen_locker}/sync_token`.
`~/.config/{diet_guard,screen_locker}/sync_token`. Needs `repo` scope — the Needs `repo` scope — the repo is private, so a token without access returns
repo is private, and a token without access returns **404, not 403**. **404, not 403**.
- `crdt_sync` is imported at module scope but **guarded by - `crdt_sync` is imported at module scope but **guarded by
`try/except ImportError`**, exactly like `python-kasa` in `_smart_plug.py`: `try/except ImportError`**: the service runs `/usr/bin/python`, not a venv, so
the service runs `/usr/bin/python`, not a venv, so a dependency that failed a dependency that failed to install must degrade the read to the GitHub path
to install must degrade the read to the GitHub path rather than crash the rather than crash the per-minute trigger. The GitHub path stays stdlib-only
per-minute trigger. The GitHub path stays stdlib-only for that reason. for that reason. Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the
Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the dev venv. dev venv.
- Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch → - Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch → stale
stale cache → `FALLBACK_ALARM_HOUR`. The cache cache → `FALLBACK_ALARM_HOUR`. The cache bounds remote reads to ~288/day
(`~/.config/wake_alarm/alarm_cache.json`) is what bounds remote reads to despite the per-minute tick.
~288/day despite the per-minute tick, and what keeps a network blip from
costing a wake-up.
- **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute, - **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute,
so a persistent condition logged loudly is 1440 identical journal lines a so a persistent condition logged loudly is 1440 identical journal lines a day.
day. `_trigger` emits the single WARNING that matters: it is firing *now* on `_trigger` emits the single WARNING that matters: it is firing *now* on a
a non-phone alarm time. 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.
## The morning_routine orchestrator lives elsewhere ## The morning_routine orchestrator lives elsewhere
`morning_routine._orchestrator` (in `testsAndMisc/python_pkg/morning_routine/`) `morning_routine._orchestrator` (in `testsAndMisc/python_pkg/morning_routine/`)
runs this package and `screen_locker` as two sequential subprocesses. When is what `_trigger` starts, and it now runs one leg: `screen_locker.screen_lock`.
either package is extracted to its own repo, **the orchestrator's module Its alarm leg was removed in the same change that deleted `_alarm.py`.
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.
## 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 ## Production dependency installation
new non-stdlib dependency (this package itself, `gatelock`, `python-kasa`,
anything added later) must be installed into system Python's *user* `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: site-packages:
```bash ```bash
/usr/bin/python3 -m pip install --user --break-system-packages -e . /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 **Run `install.sh` (or `pip install -e`) from a durable clone**, not a scratch
scratch directory.** `install.sh` does `pip install -e "$REPO_DIR"` — directory — it is editable, so a later `git pull` in that clone updates the
editable, so a later `git pull` in that same clone updates the running running production code. From `/tmp` or via `pip install "wake_alarm @ git+..."`
production code with no reinstall needed. The clone must live somewhere you get a snapshot frozen at that commit, and the next push silently does not
permanent (this repo's convention: `~/wake-alarm`, mirroring `~/screen-locker` reach the running service. Convention: `~/wake-alarm`, mirroring
for the screen-locker package) — if you `pip install -e` from `/tmp/...` or `~/screen-locker`.
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.
`install.sh` already does this. **If you add a dependency and only install it If you add a dependency and only install it into a dev venv, the service fails
into a dev venv, the production service will silently fail with with `ModuleNotFoundError` on its next run — this exact gap caused a 3-day
`ModuleNotFoundError` on its next run** — this exact gap caused a 3-day diet_guard outage. Verify against `/usr/bin/python3 -c "import <dep>"`.
diet_guard production outage (2026-06-19 to 2026-06-22) for the sibling
`gatelock` migration. Always verify against ## Uninstalling
`/usr/bin/python3 -c "import <new_dep>"`, not just the dev venv, before
considering a dependency change done. `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 ## 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 - **`wake_state.json` is runtime state, not tracked.** It holds the
HMAC-signed dismissal record for the current alarm day, was accidentally HMAC-signed once-per-day latch. It was accidentally committed in the
committed in the monorepo, and must stay gitignored here. 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 ## Commands
- Run tests: `python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100` - 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` - 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` (`--no-cache` forces a live read)
- Show the resolved alarm: `python -m wake_alarm._sync --show` (add
`--no-cache` to force a live GitHub read)
- Show what the next tick would do: `python -m wake_alarm._trigger --dry-run` - Show what the next tick would do: `python -m wake_alarm._trigger --dry-run`
- Watch it live: `journalctl --user -fu wake-alarm-trigger.service` - 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` - Gate the shell/system scripts: `bash scripts/vmbox/harness.sh`
(disposable Arch VM; coverage and limits in **`DOCS-vmbox.md`** — notably (disposable Arch VM; see **`DOCS-vmbox.md`**)
*not* audio, the phone, or the bootloader). - 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 The dev venv lacks `gatelock`, so anything importing `_state` (including
`_trigger`) needs `/usr/bin/python3`, not `.venv/bin/python`. `_trigger`) needs `/usr/bin/python3`, not `.venv/bin/python`.
## Do NOT ## Do NOT
- Don't add a dependency without doing the production install-path check - Don't add a dependency without the production install-path check above.
above. `_sync.py`'s **GitHub** path is stdlib-only for exactly this reason — `_sync.py`'s GitHub path is stdlib-only for that reason — don't "improve" it
don't "improve" it to `requests`, and don't make the `crdt_sync` import to `requests`, and don't make the `crdt_sync` import unconditional.
unconditional: the `try/except ImportError` guard is what keeps a failed - Don't forget the orchestrator when changing this package's module paths.
install from killing the trigger. - Don't point the trigger at anything but `morning-routine.service`, or the
- Don't forget the orchestrator when changing this package's module path — workout lock never runs.
see "The morning_routine orchestrator lives elsewhere" above. - Don't remove the trigger's once-per-day latch.
- Don't give `wake-alarm.service` an `[Install]` section: it fires at login - Don't raise the log level of `_sync`'s degraded-read paths.
time, not alarm time. - Don't let the bypass skip the walk, and don't add a challenge kind that
- Don't point the trigger at `wake-alarm.service`; it must start hides its own prompt.
`morning-routine.service`, or the workout lock never runs. - Don't delete `/etc/sudoers.d/wake-alarm`.
- 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 commit `wake_state.json`. - Don't commit `wake_state.json`.

View File

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

View File

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

View File

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

View File

@ -28,42 +28,21 @@ those wrong on the host is not a failed test, it is a broken machine.
| Case | What a pass means | | 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 | | `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` | | `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 |
| `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-dry-run` | one tick reaches a decision without crashing | | `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 `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. it fails, the harness stops with exit 2 and dumps the log.
## What it cannot cover — do not read a pass as covering these ## 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 - **The phone.** The entire dismiss sequence — two NFC tags, the step counter,
and reactivation path — the whole of item 6 — is untestable here and was the challenges, the 5-minute bypass — lives in `phone_app/` and is verified
verified directly on the host instead. on the Pixel 6a, never here. This harness knows nothing about it.
- **That the power button is actually blocked.** The guest has no physical - **The bootloader.** The guest is BIOS/GRUB, this PC is UEFI. Any
power button, *and* its polkit refuses an inhibit from a non-interactive ssh firmware/ESP work goes straight to the host.
session outright (`Failed to inhibit: Access denied`). So the case asserts - **A green run is not host verification.** Report it as "passed in vmbox, not
something narrower but still useful: that the code never *claims* a block it verified on the host", always.
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".
## Guest provisioning, and why ## 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 makes rsync write into `/home` and fail with permission errors that look
like a guest problem. 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: Both were live bugs, both passed the unit suite:

View File

@ -1,52 +1,78 @@
# wake_alarm # wake_alarm
A phone-driven wake alarm for an always-on PC. The alarm time is whatever the **The phone is the alarm.** `phone_app/` arms and rings it, and getting past it
companion phone app last synced (via the shared `crdt_sync` transport over a means walking between two NFC tags and answering three questions. This repo's
private GitHub repo); a per-minute systemd user timer fires at exactly that Python half no longer wakes anyone: all it does is read the alarm time the
time, showing a fullscreen, challenge-dismissed alarm with escalating audio phone published and start the morning routine (the workout lock) at that time.
and optional TP-Link Tapo P110 smart-plug control.
Change the alarm on the phone at any hour and the PC follows within ~5 ## The dismiss sequence
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. 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 ## Install
```bash ```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 `install.sh` installs the package into system Python's user site-packages (the
site-packages (the systemd units run `/usr/bin/python` directly, not a venv — units run `/usr/bin/python` directly, not a venv — see `CLAUDE.md`) and enables
see `CLAUDE.md`), installs and enables the per-minute trigger timer, the the timer. It also clears the retired pieces an older install left behind,
systemd-sleep resume hook, the `shutdown` wrapper that hibernates with notably `/usr/local/bin/shutdown`, which is root-owned and would otherwise keep
`rtcwake` as a fallback, and (optionally) `python-kasa` for smart-plug turning `shutdown now` into a hibernate forever.
control.
Reading the phone's alarm needs a GitHub token with `repo` scope, at Reading the phone's alarm uses the shared `~/.config/crdt-sync/firebase.json`.
`~/.config/wake_alarm/sync.json` as `{"token": "..."}`. If that file is The GitHub mirror (fallback only) needs a `repo`-scoped token at
absent, a sibling app's `~/.config/{diet_guard,screen_locker}/sync_token` is `~/.config/wake_alarm/sync.json` as `{"token": "..."}`.
used instead, so one token covers every app syncing through `kuhyx/syncs`.
## Usage ## Usage
```bash ```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 python -m wake_alarm._trigger --dry-run # what the next tick would do
journalctl --user -fu wake-alarm-trigger.service # watch it live 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 ## Development
```bash ```bash
python -m venv .venv && .venv/bin/pip install -r requirements.txt 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/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 .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 dev venv lacks `gatelock`, so anything importing `_state` (including
the cross-repo `workout_log.json` read, and production deployment gotchas. `_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.

View File

@ -25,11 +25,12 @@ starting; this file deliberately does not duplicate it.
## Remaining, in order ## Remaining, in order
1. **Item 3 leftover: the QR scanner UI only.** `mobile_scanner`, feeding 1. ~~QR scanner UI~~ — **cancelled 2026-08-30.** QR was deleted rather than
`RingScreen.onQrScanned`, which already exists and is unit-tested. The finished: it had never been reachable at ring time (no scanner package, no
payload is registered in Settings → "Room proof" and persisted. NFC and the CAMERA permission, `onQrScanned` had no caller), and a printed code is
steps fallback both work, so the alarm is dismissable without this — QR is photographable, so completing it would have added a bypass to the very gate
the third mechanism, not a missing one. 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 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). that legitimately touches `_sync.py`, so it carries that split (see below).
3. **Item 13, black screen.** Different repo (`~/testsAndMisc/ 3. **Item 13, black screen.** Different repo (`~/testsAndMisc/
@ -118,8 +119,9 @@ Two harness traps found while testing this, worth keeping:
### Still unverified ### Still unverified
- The **QR** proof has no scanner UI (`mobile_scanner`); `checkQr` and its - The **two-tag sequence** has not been walked end to end on the phone: only
persistence are unit-tested 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 - 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. was on an unlocked phone. The PIN-prompt path is implemented but unproven.

View File

@ -1,29 +1,31 @@
#!/bin/bash #!/bin/bash
# Install the weekend wake alarm systemd user service and sudoers entry. # Install the wake-alarm trigger timer.
# #
# Usage: bash install.sh # 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: # What it does:
# 1. Installs wake_alarm + dependencies for /usr/bin/python # 1. Installs wake_alarm + dependencies for /usr/bin/python
# 2. Installs system dependencies (alsa-utils, ddcutil) # 2. Installs the systemd user units and enables the per-minute trigger timer
# 3. Installs the systemd user units and enables the per-minute trigger timer # 3. Removes the retired waking machinery from earlier installs
# (which fires the alarm at the time the phone synced) #
# 4. Installs the systemd-sleep hook (restarts alarm after hibernate resume) # Deliberately NOT done any more, and why:
# 5. Adds a sudoers entry for passwordless rtcwake # - no shutdown wrapper, no rtcwake, no sleep hook: the PC does not wake you
# 6. Installs shutdown wrapper so "shutdown now" also hibernates on alarm nights # - the /etc/sudoers.d/wake-alarm entry is left ALONE. screen-locker's own
# 7. Removes the retired fan-control script + its sudoers entry # _wake_alarm.py still shells out to `sudo rtcwake`, so removing it here
# 8. Installs python-kasa (AUR) so the alarm can toggle a Tapo P110 smart plug # would silently break a live feature in another repo.
# 9. Installs ddcutil and grants /dev/i2c-* access for DDC/CI monitor control
set -euo pipefail set -euo pipefail
# Refuse to run under sudo. This script must run as the logged-in user: it # 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 # 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, # 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 # so `systemctl --user` fails and later steps are silently skipped while the
# shutdown wrapper at step 6 -- is silently skipped while the earlier pip output # earlier pip output still looks like success. It also runs pip as root, which
# still looks like success. It also runs pip as root, which leaves root-owned # leaves root-owned files in ~/.cache/pip. Failing loudly beats half-finishing.
# files in ~/.cache/pip. Failing loudly here beats a half-finished install.
if [[ -n "${SUDO_USER:-}" || $EUID -eq 0 ]]; then if [[ -n "${SUDO_USER:-}" || $EUID -eq 0 ]]; then
echo "Error: do not run this script with sudo." >&2 echo "Error: do not run this script with sudo." >&2
echo "Run it as your normal user: bash $0" >&2 echo "Run it as your normal user: bash $0" >&2
@ -35,139 +37,35 @@ fi
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
readonly SCRIPT_DIR readonly SCRIPT_DIR
readonly REPO_DIR="$SCRIPT_DIR" readonly REPO_DIR="$SCRIPT_DIR"
SERVICE_FILE="$SCRIPT_DIR/wake-alarm.service" readonly TRIGGER_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-trigger.service"
TRIGGER_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-trigger.service" readonly TRIGGER_TIMER_SRC="$SCRIPT_DIR/wake-alarm-trigger.timer"
TRIGGER_TIMER_SRC="$SCRIPT_DIR/wake-alarm-trigger.timer" readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
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"
echo "=== Weekend Wake Alarm Installer ===" echo "=== Wake Alarm Trigger Installer ==="
# 1. Install this package + its dependencies into system Python ------------- echo "[1/3] Installing wake_alarm for /usr/bin/python..."
echo "[1/9] Installing wake_alarm + dependencies 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" /usr/bin/python3 -m pip install --user --break-system-packages -e "$REPO_DIR"
echo " Installed. Verifying import..." echo " Installed. Verifying import..."
/usr/bin/python3 -c "import wake_alarm; import gatelock" \ /usr/bin/python3 -c "import wake_alarm; import gatelock"
&& echo " wake_alarm and gatelock import cleanly from the system interpreter." echo " wake_alarm and gatelock import under /usr/bin/python."
# 2. Install system dependencies echo "[2/3] Installing systemd user units..."
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..."
mkdir -p "$SYSTEMD_USER_DIR" mkdir -p "$SYSTEMD_USER_DIR"
cp "$SERVICE_FILE" "$SYSTEMD_USER_DIR/wake-alarm.service" cp "$TRIGGER_SERVICE_SRC" "$TRIGGER_TIMER_SRC" "$SYSTEMD_USER_DIR/"
cp "$TRIGGER_SERVICE_SRC" "$SYSTEMD_USER_DIR/wake-alarm-trigger.service"
cp "$TRIGGER_TIMER_SRC" "$SYSTEMD_USER_DIR/wake-alarm-trigger.timer"
systemctl --user daemon-reload 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 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 "[3/3] Removing retired waking machinery from earlier installs..."
echo "[4/9] Installing systemd-sleep hook..." bash "$SCRIPT_DIR/uninstall.sh" --retired-only
sudo cp "$SLEEP_HOOK_SRC" "$SLEEP_HOOK_DST" echo
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 "=== Installation complete ===" echo "=== Installation complete ==="
echo "The trigger timer ticks once a minute and fires the alarm at the time" echo "The timer ticks once a minute and starts morning-routine.service at the"
echo "the phone last synced (needs ~/.config/wake_alarm/sync.json with a" echo "time the phone last synced."
echo "repo-scoped GitHub token — see MIGRATION.md)." echo "Check what it resolves right now: python -m wake_alarm._sync --show"
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 "If the PC is powered down instead, the shutdown wrapper hibernates with" echo "Watch it live: journalctl --user -fu wake-alarm-trigger.service"
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"

View File

@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "wake-alarm" name = "wake-alarm"
version = "1.0.0" 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" requires-python = ">=3.14"
dependencies = [ dependencies = [
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.2#subdirectory=gatelock", "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 # 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. # 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", "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 # 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",
"use-implicit-booleaness-not-comparison-to-string", "use-implicit-booleaness-not-comparison-to-string",
"use-implicit-booleaness-not-comparison-to-zero", "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] [tool.pylint.design]
@ -143,9 +138,6 @@ max-attributes = 10
[tool.pylint.typecheck] [tool.pylint.typecheck]
# unittest.mock.MagicMock generates assertion/introspection methods at runtime. # 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 = [ generated-members = [
".*\\.assert_called_once_with", ".*\\.assert_called_once_with",
".*\\.assert_called_once", ".*\\.assert_called_once",
@ -155,11 +147,6 @@ generated-members = [
".*\\.call_args", ".*\\.call_args",
".*\\.call_args_list", ".*\\.call_args_list",
".*\\.call_count", ".*\\.call_count",
".*\\.setnchannels",
".*\\.setsampwidth",
".*\\.setframerate",
".*\\.writeframes",
".*\\.writeframesraw",
] ]
[tool.bandit] [tool.bandit]

View File

@ -1,9 +1,7 @@
# Wake Alarm — runtime + development dependencies # Wake Alarm — runtime + development dependencies
# Runtime: tkinter/json/hmac/wave/subprocess (stdlib) plus gatelock, # Runtime: json/hmac/subprocess (stdlib) plus gatelock and crdt-sync below.
# crdt-sync and python-kasa below. crdt-sync and python-kasa are both # crdt-sync is optional at runtime — the alarm read falls back to GitHub if
# optional at runtime — their modules degrade gracefully if missing (the # it is missing — but it is required here for mypy/pylint/tests.
# alarm read falls back to GitHub, smart-plug control disables itself) — but
# both are required here for mypy/pylint/tests.
bandit==1.9.4 bandit==1.9.4
codespell==2.4.3 codespell==2.4.3
coverage==7.16.0 coverage==7.16.0
@ -17,5 +15,4 @@ pytest-cov==7.1.0
pytest-randomly==4.1.0 pytest-randomly==4.1.0
pytest-sugar==1.1.1 pytest-sugar==1.1.1
pytest-xdist==3.8.0 pytest-xdist==3.8.0
python-kasa==0.10.2
ruff==0.16.5 ruff==0.16.5

View File

@ -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())

View File

@ -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()

View File

@ -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()

View File

@ -15,10 +15,6 @@
set -uo pipefail set -uo pipefail
readonly PY=/usr/bin/python3 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 FAILURES=0
pass() { pass() {
@ -42,9 +38,9 @@ case_imports() {
fi fi
} }
# --- Case: units land in the right enablement state -------------------------- # --- Case: the trigger timer is actually enabled -----------------------------
# wake-alarm.service must stay `static`. An [Install] section there fires the # install.sh writing the unit file is not the same as systemd running it, and
# alarm at *login* time, because _should_run_alarm() has no time gate. # a timer that is present but disabled fails silently every morning.
case_units() { case_units() {
local name state local name state
name="trigger-timer-enabled" name="trigger-timer-enabled"
@ -54,42 +50,6 @@ case_units() {
else else
fail "$name" "expected enabled, got '$state'" fail "$name" "expected enabled, got '$state'"
fi 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 ------------------------------ # --- Case: the trigger decides without crashing ------------------------------
@ -102,80 +62,15 @@ case_trigger_dry_run() {
fi 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() { main() {
case "${1:-all}" in 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) all)
case_imports case_imports
case_units case_units
case_shutdown_wrapper
case_wrapper_reads_cache
case_trigger_dry_run case_trigger_dry_run
case_inhibitor
case_refire
;; ;;
*) *)
echo "usage: $0 [all|window]" >&2 echo "usage: $0 [all]" >&2
exit 2 exit 2
;; ;;
esac esac

View File

@ -11,14 +11,13 @@
# What it deliberately cannot cover -- audio, the phone, real outputs, the # What it deliberately cannot cover -- audio, the phone, real outputs, the
# bootloader -- is in DOCS-vmbox.md. Read that before trusting a pass. # bootloader -- is in DOCS-vmbox.md. Read that before trusting a pass.
# #
# Usage: bash scripts/vmbox/harness.sh [--name <sandbox>] [--keep] [--window] # Usage: bash scripts/vmbox/harness.sh [--name <sandbox>] [--keep]
# ============================================================================ # ============================================================================
set -euo pipefail set -euo pipefail
VM_NAME="wake-alarm-harness" VM_NAME="wake-alarm-harness"
KEEP=0 KEEP=0
RUN_WINDOW=0
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
readonly SCRIPT_DIR readonly SCRIPT_DIR
# Absolute and symlink-resolved: `vm share` and `vm repo` both key off the # Absolute and symlink-resolved: `vm share` and `vm repo` both key off the
@ -29,11 +28,6 @@ readonly REPO_DIR
SCRIPT_NAME="$(basename "$0")" SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME 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(). # Printed by guest_cases.sh only when every case passed. See run_cases().
readonly GUEST_OK_SENTINEL="ALL-CASES-PASSED" readonly GUEST_OK_SENTINEL="ALL-CASES-PASSED"
@ -46,7 +40,6 @@ Run wake_alarm's system-level cases in a disposable Arch VM.
Options: Options:
--name <sandbox> Sandbox name (default: $VM_NAME) --name <sandbox> Sandbox name (default: $VM_NAME)
--keep Leave the sandbox running afterwards, for poking at --keep Leave the sandbox running afterwards, for poking at
--window Also open the real alarm window and screenshot it
-h, --help Show this help -h, --help Show this help
Exit codes: Exit codes:
@ -90,11 +83,6 @@ provision_sandbox() {
step "Sharing the repo with the guest" step "Sharing the repo with the guest"
vm share "$REPO_DIR" >/dev/null 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 # --worktree, not a clone: the point of the gate is to test the edits in
# front of you, including the ones not committed yet. # front of you, including the ones not committed yet.
step "Copying the working tree into the guest" step "Copying the working tree into the guest"
@ -125,21 +113,6 @@ run_cases() {
grep -q "$GUEST_OK_SENTINEL" <<<"$output" 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() { cleanup() {
if [[ $KEEP -eq 1 ]]; then if [[ $KEEP -eq 1 ]]; then
echo "Sandbox '$VM_NAME' left running (--keep). Poke at it with: vm ssh $VM_NAME" echo "Sandbox '$VM_NAME' left running (--keep). Poke at it with: vm ssh $VM_NAME"
@ -157,9 +130,6 @@ main() {
local status=0 local status=0
run_cases || status=1 run_cases || status=1
if [[ $RUN_WINDOW -eq 1 ]]; then
run_window_case || status=1
fi
echo "============================================================" echo "============================================================"
if [[ $status -eq 0 ]]; then if [[ $status -eq 0 ]]; then
@ -182,10 +152,6 @@ while [[ $# -gt 0 ]]; do
KEEP=1 KEEP=1
shift shift
;; ;;
--window)
RUN_WINDOW=1
shift
;;
-h|--help) -h|--help)
usage usage
;; ;;

View File

@ -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": <unix seconds>} — 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

View File

@ -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)

100
uninstall.sh Executable file
View File

@ -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 "$@"

View File

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

View File

@ -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()

View File

@ -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,
)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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("<Return>", 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 <space> 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,
)

View File

@ -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=<sink>`` 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 ``<defunct>`` 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)

View File

@ -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

View File

@ -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,
)

View File

@ -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,
),
)()

View File

@ -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 from __future__ import annotations
@ -8,14 +13,6 @@ from pathlib import Path
# Every day of the week. # Every day of the week.
ALARM_DAYS: frozenset[int] = frozenset({0, 1, 2, 3, 4, 5, 6}) 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 # 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. # 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. # the one direction the whole design refuses to allow.
FALLBACK_ALARM_HOUR: int = 8 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) # State file for wake alarm (HMAC-signed)
WAKE_STATE_FILE: Path = Path(__file__).resolve().parent / "wake_state.json" 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) ----------------------------- # --- Phone alarm sync (crdt_sync over GitHub) -----------------------------
# The phone app pushes its alarm to a private repo as a crdt_sync log; the PC # 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 # 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. # 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" 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. # 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" 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 # Structured logs live under XDG state, not .config: they are churning runtime
# data, not configuration. The JSONL file is additive -- the journal keeps # 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 # wake-alarm.service directly: the orchestrator runs the alarm first and the
# workout lock after, so the two never fight for the fullscreen. # workout lock after, so the two never fight for the fullscreen.
TRIGGER_TARGET_UNIT: str = "morning-routine.service" 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

View File

@ -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())

View File

@ -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)

View File

@ -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

View File

@ -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"))

View File

@ -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)

View File

@ -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 <space> on Button on X11, never <Return>, 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)

View File

@ -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

View File

@ -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)

View File

@ -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"})

View File

@ -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

View File

@ -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,
)

View File

@ -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!")

View File

@ -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)

View File

@ -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

View File

@ -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 <args>`` 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)

View File

@ -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)

View File

@ -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)

View File

@ -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 from __future__ import annotations
@ -11,7 +20,7 @@ from gatelock.log_integrity import (
verify_entry_hmac, 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__) _logger = logging.getLogger(__name__)
@ -21,24 +30,18 @@ def _today_str() -> str:
return datetime.now(tz=UTC).strftime("%Y-%m-%d") return datetime.now(tz=UTC).strftime("%Y-%m-%d")
def save_wake_state( def save_trigger_fired(*, fired_at: str) -> bool:
*, """Record that the morning routine was started today.
dismissed_at: str | None,
skip_workout: bool,
) -> bool:
"""Write today's wake state with HMAC signature.
Args: Args:
dismissed_at: ISO time when alarm was dismissed, or None. fired_at: ISO timestamp of the tick that started the routine.
skip_workout: Whether the user earned a workout skip.
Returns: Returns:
True if saved successfully, False otherwise. True if saved successfully, False otherwise.
""" """
entry: dict[str, object] = { entry: dict[str, object] = {
"date": _today_str(), "date": _today_str(),
"dismissed_at": dismissed_at, "fired_at": fired_at,
"skip_workout": skip_workout,
} }
signature = compute_entry_hmac(entry) signature = compute_entry_hmac(entry)
if signature is not None: if signature is not None:
@ -53,16 +56,12 @@ def save_wake_state(
_logger.warning("Failed to save wake state: %s", exc) _logger.warning("Failed to save wake state: %s", exc)
return False return False
_logger.info( _logger.info("Recorded trigger fired at %s", fired_at)
"Saved wake state: dismissed=%s skip=%s",
dismissed_at,
skip_workout,
)
return True return True
def load_wake_state() -> dict[str, object] | None: 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 Returns the state dict if it exists, is valid (HMAC OK), and is
for today. Returns None otherwise. for today. Returns None otherwise.
@ -90,40 +89,9 @@ def load_wake_state() -> dict[str, object] | None:
return state return state
def has_workout_skip_today() -> bool: def was_trigger_fired_today() -> bool:
"""Check if the user earned a workout skip for today.""" """Check whether the morning routine was already started today."""
state = load_wake_state() state = load_wake_state()
if state is None: if state is None:
return False return False
return bool(state.get("skip_workout")) return state.get("fired_at") is not None
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

View File

@ -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("<h", sample))
wav.writeframesraw(bytes(frames))
_TONE_CACHE[frequency] = path
return path

View File

@ -26,7 +26,6 @@ import logging
import subprocess import subprocess
import sys import sys
from wake_alarm._alarm_session import should_refire
from wake_alarm._constants import ( from wake_alarm._constants import (
ALARM_DAYS, ALARM_DAYS,
FALLBACK_ALARM_HOUR, FALLBACK_ALARM_HOUR,
@ -35,8 +34,8 @@ from wake_alarm._constants import (
) )
from wake_alarm._logging_setup import configure_logging from wake_alarm._logging_setup import configure_logging
from wake_alarm._state import ( from wake_alarm._state import (
was_alarm_dismissed_today, save_trigger_fired,
was_workout_logged_today, was_trigger_fired_today,
) )
from wake_alarm._sync import Alarm, get_alarm from wake_alarm._sync import Alarm, get_alarm
@ -83,7 +82,7 @@ def _skip_reason(now: datetime) -> str | None:
"""Return why the alarm must not fire right now, or None if it may. """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 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: Args:
now: Current local time. now: Current local time.
@ -93,10 +92,8 @@ def _skip_reason(now: datetime) -> str | None:
""" """
if now.weekday() not in ALARM_DAYS: if now.weekday() not in ALARM_DAYS:
return "not an alarm day" return "not an alarm day"
if was_alarm_dismissed_today(): if was_trigger_fired_today():
return "alarm already dismissed today" return "already fired today"
if was_workout_logged_today():
return "workout already logged today"
return None return None
@ -140,15 +137,6 @@ def _decide(now: datetime, alarm: Alarm) -> tuple[bool, str]:
if skip is not None: if skip is not None:
return False, skip 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) fire_at, window_end = _fire_window(alarm, now)
if now < fire_at: if now < fire_at:
return False, f"alarm {alarm} is still ahead" return False, f"alarm {alarm} is still ahead"
@ -235,7 +223,12 @@ def main() -> None:
) )
_logger.warning("Firing %s: %s", TRIGGER_TARGET_UNIT, reason) _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__": if __name__ == "__main__":

View File

@ -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]

View File

@ -1,40 +1,26 @@
"""Shared fixtures keeping the wake_alarm suite off the real machine. """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 Two hazards are left now that the PC no longer owns a lock screen, audio or
output. Two consequences for tests, both of which bite silently: an inhibitor:
* ``tk.Toplevel`` over a ``MagicMock`` master sends real tkinter into infinite * ``_state`` signs its records with gatelock's HMAC key, and the real key is
mock recursion -- the run **hangs** rather than failing, which is far harder root-owned but *world-readable*. An unpatched run would sign test entries
to diagnose than an error. with the **production** key on this machine and with none at all anywhere
* An unpatched lock setup shells out to ``setxkbmap`` and really disables VT else, so the suite would pass here and behave differently in CI.
switching on the machine running the tests, and reaches the real * ``WAKE_STATE_FILE`` is a real path inside the repo, and ``_trigger`` now
``$XDG_RUNTIME_DIR``, where it could stand a *production* locker down. *writes* it. The trigger tests never redirected it, because until the
once-per-day latch moved into the trigger nothing on that path wrote
Sink reactivation adds a fourth: ``WakeAlarm.__init__`` starts a background anything. Redirecting it here rather than per-file is deliberate: a
thread that really shells out to ``pactl set-card-profile`` and would reshape per-file redirect is exactly the kind that gets forgotten when a new writer
the *host's* audio routing mid-test. appears, and the failure is silent -- a green suite that quietly stamped
the live state file.
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.
""" """
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch from unittest.mock import patch
from gatelock import Output, OutputRect
import pytest import pytest
if TYPE_CHECKING: if TYPE_CHECKING:
@ -42,83 +28,13 @@ if TYPE_CHECKING:
from pathlib import Path 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) @pytest.fixture(autouse=True)
def _hermetic(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[None]: def _hermetic(tmp_path: Path) -> Generator[None]:
"""Block every path from the lock to the real display or machine.""" """Keep signing keys and state writes inside ``tmp_path``."""
monkeypatch.setenv("GATELOCK_RUNTIME_DIR", str(tmp_path / "gatelock-runtime"))
key_file = tmp_path / "hmac.key" key_file = tmp_path / "hmac.key"
key_file.write_bytes(b"0" * 32) key_file.write_bytes(b"0" * 32)
with ( 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("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key_file),
patch( patch("wake_alarm._state.WAKE_STATE_FILE", tmp_path / "wake_state.json"),
"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"),
): ):
yield 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

View File

@ -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)

View File

@ -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()

View File

@ -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"}

View File

@ -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()

View File

@ -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,
)

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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=<sink> 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")

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

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

View File

@ -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)

View File

@ -11,7 +11,7 @@ import pytest
from wake_alarm._state import ( from wake_alarm._state import (
_today_str, _today_str,
load_wake_state, load_wake_state,
save_wake_state, save_trigger_fired,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -46,7 +46,7 @@ class TestTodayStr:
class TestSaveWakeState: class TestSaveWakeState:
"""Tests for save_wake_state.""" """Tests for save_trigger_fired."""
def test_saves_with_hmac(self, wake_state_file: Path) -> None: def test_saves_with_hmac(self, wake_state_file: Path) -> None:
"""Save state with HMAC signature when key is available.""" """Save state with HMAC signature when key is available."""
@ -54,15 +54,11 @@ class TestSaveWakeState:
"wake_alarm._state.compute_entry_hmac", "wake_alarm._state.compute_entry_hmac",
return_value="fakesig", return_value="fakesig",
): ):
result = save_wake_state( result = save_trigger_fired(fired_at="2026-04-12T07:04:00+00:00")
dismissed_at="2026-04-12T07:04:00+00:00",
skip_workout=True,
)
assert result is True assert result is True
data = json.loads(wake_state_file.read_text()) data = json.loads(wake_state_file.read_text())
assert data["skip_workout"] is True assert data["fired_at"] == "2026-04-12T07:04:00+00:00"
assert data["dismissed_at"] == "2026-04-12T07:04:00+00:00"
assert data["hmac"] == "fakesig" assert data["hmac"] == "fakesig"
assert data["date"] == _today_str() assert data["date"] == _today_str()
@ -72,14 +68,10 @@ class TestSaveWakeState:
"wake_alarm._state.compute_entry_hmac", "wake_alarm._state.compute_entry_hmac",
return_value=None, return_value=None,
): ):
result = save_wake_state( result = save_trigger_fired(fired_at="07:04")
dismissed_at=None,
skip_workout=False,
)
assert result is True assert result is True
data = json.loads(wake_state_file.read_text()) data = json.loads(wake_state_file.read_text())
assert data["skip_workout"] is False
assert "hmac" not in data assert "hmac" not in data
def test_returns_false_on_write_error(self, wake_state_file: Path) -> None: 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", 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 assert result is False
@ -111,7 +103,7 @@ class TestLoadWakeState:
wake_state_file: Path, wake_state_file: Path,
) -> None: ) -> None:
"""Return None when state is from a different day.""" """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)) wake_state_file.write_text(json.dumps(state))
assert load_wake_state() is None assert load_wake_state() is None
@ -138,8 +130,7 @@ class TestLoadWakeState:
"""Return None when HMAC verification fails.""" """Return None when HMAC verification fails."""
state = { state = {
"date": _today_str(), "date": _today_str(),
"skip_workout": True, "fired_at": "07:00",
"dismissed_at": "07:00",
"hmac": "badsig", "hmac": "badsig",
} }
wake_state_file.write_text(json.dumps(state)) wake_state_file.write_text(json.dumps(state))
@ -156,8 +147,7 @@ class TestLoadWakeState:
"""Return state dict when file is valid and for today.""" """Return state dict when file is valid and for today."""
state = { state = {
"date": _today_str(), "date": _today_str(),
"skip_workout": True, "fired_at": "07:04",
"dismissed_at": "07:04",
"hmac": "validsig", "hmac": "validsig",
} }
wake_state_file.write_text(json.dumps(state)) wake_state_file.write_text(json.dumps(state))
@ -168,4 +158,3 @@ class TestLoadWakeState:
result = load_wake_state() result = load_wake_state()
assert result is not None assert result is not None
assert result["skip_workout"] is True

View File

@ -14,9 +14,7 @@ import pytest
from wake_alarm._state import ( from wake_alarm._state import (
_today_str, _today_str,
has_workout_skip_today, was_trigger_fired_today,
was_alarm_dismissed_today,
was_workout_logged_today,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -39,22 +37,21 @@ def _patch_wake_state_file(wake_state_file: Path) -> None:
yield yield
class TestHasWorkoutSkipToday: class TestWasTriggerFiredToday:
"""Tests for has_workout_skip_today.""" """Tests for the trigger's once-per-day latch."""
def test_returns_false_when_no_state(self) -> None: def test_returns_false_when_no_state(self) -> None:
"""Return False when no state file exists.""" """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, self,
wake_state_file: Path, wake_state_file: Path,
) -> None: ) -> None:
"""Return True when today's state has skip_workout=True.""" """Return True when the routine already started today."""
state = { state = {
"date": _today_str(), "date": _today_str(),
"skip_workout": True, "fired_at": "07:04",
"dismissed_at": "07:04",
"hmac": "sig", "hmac": "sig",
} }
wake_state_file.write_text(json.dumps(state)) wake_state_file.write_text(json.dumps(state))
@ -62,17 +59,16 @@ class TestHasWorkoutSkipToday:
"wake_alarm._state.verify_entry_hmac", "wake_alarm._state.verify_entry_hmac",
return_value=True, 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, self,
wake_state_file: Path, wake_state_file: Path,
) -> None: ) -> None:
"""Return False when today's state has skip_workout=False.""" """Return False when it has not started yet."""
state = { state = {
"date": _today_str(), "date": _today_str(),
"skip_workout": False, "fired_at": None,
"dismissed_at": None,
"hmac": "sig", "hmac": "sig",
} }
wake_state_file.write_text(json.dumps(state)) wake_state_file.write_text(json.dumps(state))
@ -80,100 +76,4 @@ class TestHasWorkoutSkipToday:
"wake_alarm._state.verify_entry_hmac", "wake_alarm._state.verify_entry_hmac",
return_value=True, return_value=True,
): ):
assert has_workout_skip_today() is False assert was_trigger_fired_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

View File

@ -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()

View File

@ -3,15 +3,10 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
if TYPE_CHECKING:
from pathlib import Path
from wake_alarm import _alarm_session
from wake_alarm._sync import Alarm from wake_alarm._sync import Alarm
from wake_alarm._trigger import ( from wake_alarm._trigger import (
_decide, _decide,
@ -35,14 +30,12 @@ def _at(hour: int, minute: int) -> datetime:
def _ungated() -> None: def _ungated() -> None:
"""Open every gate unless a test closes one. """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 session record on the host, so a test run started while an alarm was
actually ringing would silently invert half these assertions. actually ringing would silently invert half these assertions.
""" """
with ( with (
patch("wake_alarm._trigger.was_alarm_dismissed_today", return_value=False), patch("wake_alarm._trigger.was_trigger_fired_today", return_value=False),
patch("wake_alarm._trigger.was_workout_logged_today", return_value=False),
patch("wake_alarm._trigger.should_refire", return_value=False),
): ):
yield yield
@ -102,18 +95,13 @@ class TestSkipReason:
with patch("wake_alarm._trigger.ALARM_DAYS", frozenset()): with patch("wake_alarm._trigger.ALARM_DAYS", frozenset()):
assert _skip_reason(_at(7, 0)) == "not an alarm day" assert _skip_reason(_at(7, 0)) == "not an alarm day"
def test_already_dismissed(self) -> None: def test_already_fired_today(self) -> None:
"""A dismissal recorded today blocks a re-fire.""" """The once-per-day latch blocks every later tick in the window."""
with patch( with patch(
"wake_alarm._trigger.was_alarm_dismissed_today", "wake_alarm._trigger.was_trigger_fired_today",
return_value=True, return_value=True,
): ):
assert _skip_reason(_at(7, 0)) == "alarm already dismissed today" assert _skip_reason(_at(7, 0)) == "already fired 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"
class TestDecide: class TestDecide:
@ -150,82 +138,14 @@ class TestDecide:
def test_gate_beats_the_clock(self) -> None: def test_gate_beats_the_clock(self) -> None:
"""A closed gate wins even at the exact alarm minute.""" """A closed gate wins even at the exact alarm minute."""
with patch( with patch(
"wake_alarm._trigger.was_alarm_dismissed_today", "wake_alarm._trigger.was_trigger_fired_today",
return_value=True, return_value=True,
): ):
fire, reason = _decide(_at(6, 45), Alarm(6, 45, "remote")) fire, reason = _decide(_at(6, 45), Alarm(6, 45, "remote"))
assert not fire assert not fire
assert reason == "alarm already dismissed today" assert reason == "already fired today"
def test_alarm_moved_earlier_fires_immediately(self) -> None: def test_alarm_moved_earlier_fires_immediately(self) -> None:
"""A phone alarm moved back before now fires on the very next tick.""" """A phone alarm moved back before now fires on the very next tick."""
fire, _ = _decide(_at(6, 50), Alarm(6, 40, "remote")) fire, _ = _decide(_at(6, 50), Alarm(6, 40, "remote"))
assert fire 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))

View File

@ -28,14 +28,12 @@ def _at(hour: int, minute: int) -> datetime:
def _ungated() -> None: def _ungated() -> None:
"""Open every gate unless a test closes one. """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 session record on the host, so a test run started while an alarm was
actually ringing would silently invert half these assertions. actually ringing would silently invert half these assertions.
""" """
with ( with (
patch("wake_alarm._trigger.was_alarm_dismissed_today", return_value=False), patch("wake_alarm._trigger.was_trigger_fired_today", return_value=False),
patch("wake_alarm._trigger.was_workout_logged_today", return_value=False),
patch("wake_alarm._trigger.should_refire", return_value=False),
): ):
yield yield

View File

@ -34,14 +34,12 @@ def _at(hour: int, minute: int) -> datetime:
def _ungated() -> None: def _ungated() -> None:
"""Open every gate unless a test closes one. """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 session record on the host, so a test run started while an alarm was
actually ringing would silently invert half these assertions. actually ringing would silently invert half these assertions.
""" """
with ( with (
patch("wake_alarm._trigger.was_alarm_dismissed_today", return_value=False), patch("wake_alarm._trigger.was_trigger_fired_today", return_value=False),
patch("wake_alarm._trigger.was_workout_logged_today", return_value=False),
patch("wake_alarm._trigger.should_refire", return_value=False),
): ):
yield yield
@ -97,6 +95,43 @@ class TestMain:
main() main()
return start 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: def test_fires_at_the_alarm_minute(self) -> None:
"""On the alarm minute the target unit is started.""" """On the alarm minute the target unit is started."""
start = self._run_main([], _at(6, 45), Alarm(6, 45, "remote")) start = self._run_main([], _at(6, 45), Alarm(6, 45, "remote"))