mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 17:07:04 +01:00
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:
parent
e83ff97f47
commit
e67cef0c54
@ -95,7 +95,6 @@ repos:
|
||||
- --jobs=4
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
- python-kasa
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.1#subdirectory=gatelock
|
||||
# Test suites intentionally use patterns (protected-access, magic-value
|
||||
# comparisons) that don't belong in the source-code 10/10 gate.
|
||||
@ -147,13 +146,6 @@ repos:
|
||||
entry: bash -c 'printf "%s\0" "$@" | xargs -0 -n 40 shellcheck --severity=warning' --
|
||||
language: system
|
||||
types: [shell]
|
||||
- id: screens-fit
|
||||
name: Every lock screen fits 1366x768 (no scrolling required)
|
||||
entry: python3 -m scripts.verify_screen_fits
|
||||
language: system
|
||||
files: '^wake_alarm/.*\.py$'
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
# The shared 250-line cap. This repo used to run a local script with a
|
||||
# higher limit under this same hook id, so it looked gated while letting
|
||||
# through files every other repo rejects.
|
||||
|
||||
345
AGENTS.md
345
AGENTS.md
@ -2,249 +2,208 @@
|
||||
|
||||
## What this does
|
||||
|
||||
A wake alarm: on alarm days (`ALARM_DAYS` in `_constants.py` — currently
|
||||
**every day**, `frozenset({0..6})`), the machine hibernates overnight and
|
||||
wakes itself via `rtcwake` at the configured alarm time. `wake-alarm.service`
|
||||
then opens a fullscreen Tk window that must be dismissed (with a typed challenge — see
|
||||
`_challenges.py`), plays audio through every real sink, and optionally
|
||||
toggles a TP-Link Tapo P110 smart plug via `python-kasa`.
|
||||
**The phone is the alarm.** `phone_app/` arms and rings it and gates the
|
||||
dismissal; this repo's Python half only reads the alarm time the phone
|
||||
published and starts the morning routine at that time.
|
||||
|
||||
## Scheduling — a per-minute timer, with hibernate as the fallback
|
||||
The PC's waking role was deleted on 2026-08-30: no alarm window, no audio
|
||||
escalation, no smart plug, no curtains, no `rtcwake`, no `shutdown` wrapper,
|
||||
no sleep hook. Making the PC wake someone reliably cost more than it returned.
|
||||
Everything about it is in git history; do not reintroduce a piece of it
|
||||
without saying why the phone cannot do that job.
|
||||
|
||||
The PC is **always on**. The alarm time comes from the phone, which can change
|
||||
it at any hour, so nothing about the schedule may be baked in ahead of time.
|
||||
## The dismiss sequence (phone)
|
||||
|
||||
**Primary path — `wake-alarm-trigger.timer`:**
|
||||
```
|
||||
tag 1 -> 25 steps + 2 min -> tag 2 -> 3 challenges
|
||||
```
|
||||
|
||||
1. The timer ticks every minute (`OnCalendar=*:*:00`, `Persistent=true`) and
|
||||
runs `python -m wake_alarm._trigger` — a short, stateless check-and-fire.
|
||||
2. `_trigger` resolves the alarm via `_sync.get_alarm()`, builds today's fire
|
||||
instant in **local** time, and fires iff `now` is in
|
||||
`[fire_time, fire_time + TRIGGER_CATCHUP_MINUTES)`. The catch-up window is
|
||||
what makes a suspended or just-booted machine still fire.
|
||||
3. Firing = `systemctl --user start morning-routine.service`. **Never**
|
||||
`wake-alarm.service` directly — see the orchestrator section below.
|
||||
Starting an already-active unit is a no-op, so an alarm on screen needs no
|
||||
extra dedupe state; the `was_alarm_dismissed_today()` /
|
||||
`was_workout_logged_today()` gates cover the rest.
|
||||
Owned by `phone_app/lib/services/dismiss_gate.dart`. **Strict order, and every
|
||||
stage latches** — an unknown or out-of-order tag is ignored, never a reset.
|
||||
`room_proof.dart` answers only "did this one check pass just now"; the
|
||||
ordering lives in the gate. Keep it that way: the previous version smeared the
|
||||
decision across `setState` calls and had a real bug where a wrong tap after
|
||||
passing sent the user back to the start.
|
||||
|
||||
A timer, not a resident daemon: each tick is a fresh process, so there is no
|
||||
long-lived scheduler to die silently and no wall-clock jump to handle across
|
||||
suspend/resume.
|
||||
- **Two *distinct* tags.** The same id in both slots is refused at
|
||||
registration — one tag satisfying both stages removes the walk, which is the
|
||||
entire point. Stored as a JSON list under `roomProof.nfcTagIds`; a legacy
|
||||
single `roomProof.nfcTagId` migrates into slot 1.
|
||||
- **Steps are unchanged** (`requiredSteps = 25`, `requiredElapsed = 2 min`),
|
||||
and are counted from ring start, *not* re-baselined at tag 1. Consequence:
|
||||
walking to tag 1 already banks the steps, so only the elapsed floor really
|
||||
binds — which is why the steps stage auto-polls instead of offering a button.
|
||||
- **Answers are cumulative, not consecutive.** A wrong one costs the attempt
|
||||
and nothing else.
|
||||
- **The bypass skips the tags, never the walk.** After
|
||||
`DismissGate.bypassAfter` (5 min), or immediately when NFC is off or fewer
|
||||
than two distinct tags are registered, the tag stages are skipped and the
|
||||
count rises to 6. Jumping straight to the challenges would let someone lie in
|
||||
bed for five minutes and answer questions without standing up — weaker than
|
||||
the gate this replaced. Do not "simplify" it into that.
|
||||
- **No challenge may hide its prompt.** The memorise-then-hide kind was
|
||||
deleted, not merely unrouted: the ring screen is reached by walking in from
|
||||
another room.
|
||||
|
||||
**Fallback path — hibernate (only when the PC really is powered down):**
|
||||
`RingScreen` takes an injectable `clock`. `Stopwatch` reads the wall clock,
|
||||
which `tester.pump()` does not advance, so without that seam no widget test
|
||||
can reach the two-minute floor or the five-minute bypass.
|
||||
|
||||
`shutdown-wrapper.sh` (installed to `/usr/local/bin/shutdown`, shadowing
|
||||
`/usr/bin/shutdown` via PATH order) still intercepts shutdown/poweroff, reads
|
||||
the same synced alarm, and sets `rtcwake`. `sleep-hook.sh` (in
|
||||
`/usr/lib/systemd/system-sleep/`) fires on resume (`$1 == post`) and starts
|
||||
`morning-routine.service`. This is now a backstop, not the main mechanism.
|
||||
More, including everything that is a hard crash or a silent no-ring:
|
||||
**`DOCS-phone-alarm.md`**.
|
||||
|
||||
If you change `WAKE_AFTER_HOURS` in `_constants.py`, you must also update the
|
||||
duplicate constant in `shutdown-wrapper.sh` (`WAKE_AFTER_HOURS=8`) — they are
|
||||
not wired together, by design (the shell wrapper has no Python runtime
|
||||
available at the point it intercepts `shutdown`).
|
||||
## Scheduling — a per-minute timer
|
||||
|
||||
**The wrapper no longer talks to the network or handles a token.** It reads
|
||||
`~/.config/wake_alarm/alarm_cache.json` — the cache `_sync.py` already
|
||||
refreshes every minute — and falls back to `WAKE_AFTER_HOURS` if that file is
|
||||
absent, malformed, or more than 2 days old. This removed the duplicated token
|
||||
search order (`read_sync_token()` used to mirror `SYNC_CONFIG_FILE` /
|
||||
`SYNC_TOKEN_FALLBACK_FILES`), and it is what makes the wrapper survive the
|
||||
move to Firebase unchanged: an ID token expires in about an hour, and a shell
|
||||
script at shutdown time has no safe way to refresh one. The only coupling left
|
||||
is the cache's `{"hour","minute","fetched_at"}` shape — keep the reader in the
|
||||
wrapper in step with `_write_cache()` in `_sync.py`.
|
||||
`wake-alarm-trigger.timer` ticks every minute (`OnCalendar=*:*:00`,
|
||||
`Persistent=true`) and runs `python -m wake_alarm._trigger` — a short,
|
||||
stateless check-and-fire. It resolves the alarm via `_sync.get_alarm()`, builds
|
||||
today's fire instant in **local** time, and fires iff `now` is in
|
||||
`[fire_time, fire_time + TRIGGER_CATCHUP_MINUTES)`. Firing means
|
||||
`systemctl --user start morning-routine.service`, which runs the workout lock.
|
||||
|
||||
## `wake-alarm.service` must never auto-start
|
||||
**The once-per-day latch is load-bearing.** After a successful start,
|
||||
`_trigger` writes `save_trigger_fired()` and `_skip_reason` gates on
|
||||
`was_trigger_fired_today()`. Without it the routine restarts on every tick for
|
||||
the rest of the 60-minute window: the unit is `Type=simple`, so a start while
|
||||
it runs is a no-op, but the moment the workout lock finishes the next tick
|
||||
relaunches it on top of the user. This latch replaced the alarm's own
|
||||
dismissal record when `_alarm.py` was deleted — deleting one without adding the
|
||||
other is a silent regression, and that is exactly the trap.
|
||||
|
||||
`wake-alarm.service` deliberately has **no `[Install]` section** and nothing
|
||||
starts it automatically. `_should_run_alarm()` gates on day / dismissed /
|
||||
workout-logged but has **no time gate**, so a
|
||||
`WantedBy=graphical-session.target` there fires the alarm at *login* time on
|
||||
any alarm day — which is every day now that the machine no longer reboots into
|
||||
the alarm. `install.sh` explicitly runs `systemctl --user disable
|
||||
wake-alarm.service` to clear the stale symlink from older installs.
|
||||
|
||||
The unit survives only as a manual test handle. Do not "fix" its missing
|
||||
auto-start.
|
||||
`Persistent=true` plus the 60-minute window is also what replaced the deleted
|
||||
systemd-sleep hook: a suspended or just-booted machine still fires.
|
||||
|
||||
## Reading the phone's alarm
|
||||
|
||||
`_sync.py` is the only reader of the remote alarm. `shutdown-wrapper.sh` reads
|
||||
`_sync.py`'s local cache instead of the remote file, so there is no second
|
||||
remote reader to keep in step.
|
||||
`_sync.py` is the only reader.
|
||||
|
||||
- Source of truth: a `crdt_sync` log written by `phone_app/` under
|
||||
`wake-alarm-sync/devices/<device-id>/alarm.json`. The value lives at
|
||||
`.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`. **The same logical path
|
||||
on both backends** — Firebase's namespace mirrors the repo layout — so only
|
||||
the transport differs, never the parse.
|
||||
`wake-alarm-sync/devices/<device-id>/alarm.json`, at
|
||||
`.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`. Same logical path on both
|
||||
backends, so only the transport differs, never the parse.
|
||||
- **The Firebase reader discovers device directories; it is not pinned to
|
||||
`devices/phone/`.** The phone's device id is migrating from the fixed
|
||||
constant `phone` to a per-install uuid, and a reader pinned to the old path
|
||||
would silently stop seeing the alarm the moment the phone migrated — then
|
||||
fall back to `FALLBACK_ALARM_HOUR`, i.e. wake at the wrong time. So
|
||||
`_fetch_firebase_alarm()` lists `SYNC_DEVICES_PREFIX`, reads every device's
|
||||
`alarm.json`, and picks the newest by the hour field's **HLC** (not by
|
||||
listing order), with `device_id` breaking exact ties so two devices stamped
|
||||
in the same millisecond don't alternate between ticks. A device whose
|
||||
payload doesn't parse is skipped rather than allowed to win.
|
||||
`SYNC_ALARM_PATH` remains as a last-resort read for a backend that lists
|
||||
nothing — keep it until every install has migrated. The **GitHub** fallback
|
||||
still reads the single fixed path; it is the mirror, not the primary.
|
||||
- Payload parsing/ranking lives in `_alarm_payload.py`, split out of `_sync.py`
|
||||
so the wire format is testable without network or disk (and to keep both
|
||||
files under the 500-line cap). `_sync.py` owns fetching, caching and
|
||||
fallback; `_alarm_payload.py` owns "what did the phone actually say".
|
||||
- **Firebase first, GitHub as the cutover mirror** (`fetch_remote_alarm()` is
|
||||
just `_fetch_firebase_alarm() or _fetch_github_alarm()`). Retiring the
|
||||
mirror is deleting the GitHub branch plus `SYNC_API_BASE`/`SYNC_REPO_*`.
|
||||
- Firebase config: the shared `~/.config/crdt-sync/firebase.json`; the
|
||||
refresh token is cached per app under `SYNC_APP_NAME` (`wake_alarm`), so
|
||||
this app's credential is separate from its siblings'. The config file is
|
||||
checked **before** anything is constructed, so an unconfigured machine
|
||||
constant `phone` to a per-install uuid, and a pinned reader would silently
|
||||
stop seeing the alarm and fall back to `FALLBACK_ALARM_HOUR` — i.e. the wrong
|
||||
time. `_fetch_firebase_alarm()` lists `SYNC_DEVICES_PREFIX`, reads every
|
||||
device's `alarm.json`, and picks the newest by the hour field's **HLC**, with
|
||||
`device_id` breaking exact ties. A payload that does not parse is skipped
|
||||
rather than allowed to win. `SYNC_ALARM_PATH` remains as a last-resort read
|
||||
for a backend that lists nothing.
|
||||
- Payload parsing/ranking lives in `_alarm_payload.py`: `_sync.py` owns
|
||||
fetching, caching and fallback; `_alarm_payload.py` owns "what did the phone
|
||||
actually say".
|
||||
- **Firebase first, GitHub as the cutover mirror.** Retiring the mirror is
|
||||
deleting the GitHub branch plus `SYNC_API_BASE`/`SYNC_REPO_*`.
|
||||
- Firebase config: shared `~/.config/crdt-sync/firebase.json`; the refresh
|
||||
token is cached per app under `SYNC_APP_NAME` (`wake_alarm`). The config file
|
||||
is checked **before** anything is constructed, so an unconfigured machine
|
||||
never touches the network.
|
||||
- GitHub token (fallback path only): `~/.config/wake_alarm/sync.json` key
|
||||
`token`, else a sibling app's raw
|
||||
`~/.config/{diet_guard,screen_locker}/sync_token`. Needs `repo` scope — the
|
||||
repo is private, and a token without access returns **404, not 403**.
|
||||
- GitHub token (fallback only): `~/.config/wake_alarm/sync.json` key `token`,
|
||||
else a sibling app's raw `~/.config/{diet_guard,screen_locker}/sync_token`.
|
||||
Needs `repo` scope — the repo is private, so a token without access returns
|
||||
**404, not 403**.
|
||||
- `crdt_sync` is imported at module scope but **guarded by
|
||||
`try/except ImportError`**, exactly like `python-kasa` in `_smart_plug.py`:
|
||||
the service runs `/usr/bin/python`, not a venv, so a dependency that failed
|
||||
to install must degrade the read to the GitHub path rather than crash the
|
||||
per-minute trigger. The GitHub path stays stdlib-only for that reason.
|
||||
Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the dev venv.
|
||||
- Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch →
|
||||
stale cache → `FALLBACK_ALARM_HOUR`. The cache
|
||||
(`~/.config/wake_alarm/alarm_cache.json`) is what bounds remote reads to
|
||||
~288/day despite the per-minute tick, and what keeps a network blip from
|
||||
costing a wake-up.
|
||||
`try/except ImportError`**: the service runs `/usr/bin/python`, not a venv, so
|
||||
a dependency that failed to install must degrade the read to the GitHub path
|
||||
rather than crash the per-minute trigger. The GitHub path stays stdlib-only
|
||||
for that reason. Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the
|
||||
dev venv.
|
||||
- Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch → stale
|
||||
cache → `FALLBACK_ALARM_HOUR`. The cache bounds remote reads to ~288/day
|
||||
despite the per-minute tick.
|
||||
- **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute,
|
||||
so a persistent condition logged loudly is 1440 identical journal lines a
|
||||
day. `_trigger` emits the single WARNING that matters: it is firing *now* on
|
||||
a non-phone alarm time.
|
||||
|
||||
Check what the PC currently resolves: `python -m wake_alarm._sync --show`.
|
||||
Check what it would do right now: `python -m wake_alarm._trigger --dry-run`.
|
||||
|
||||
## The phone rings on its own
|
||||
|
||||
`phone_app/` is no longer a time picker: it arms and rings its own alarm
|
||||
(items 1/4) via five Kotlin files behind **one** MethodChannel, gating
|
||||
dismissal behind a room proof plus a challenge. **Never replace that with a
|
||||
pub plugin, and never move the ring off `STREAM_ALARM`.** Everything that is
|
||||
a hard crash or a silent no-ring is in **`DOCS-phone-alarm.md`**.
|
||||
|
||||
## Module map
|
||||
|
||||
`_alarm.py` owns the window and the dismiss flow and nothing else. The full
|
||||
map of which module answers which question, the HDMI-silence root cause, and
|
||||
the timing constants that must not collide are in **`DOCS-architecture.md`**.
|
||||
Read it before changing the escalation or the failsafe.
|
||||
|
||||
## Cross-repo coupling — not a bug
|
||||
|
||||
`_constants.py`'s `WORKOUT_LOG_FILE` points at
|
||||
`~/screen-locker/screen_locker/workout_log.json` — a file owned by the
|
||||
separate, already-standalone `screen-locker` repo
|
||||
(https://github.com/kuhyx/screen-locker). This is intentional: the alarm
|
||||
reads whether today's workout was already logged by screen-locker to decide
|
||||
whether the morning routine should also lock the workout screen. If this
|
||||
path ever raises `ModuleNotFoundError`-style confusion, the bug is almost
|
||||
certainly in the **orchestrator** (`morning_routine` in testsAndMisc), not
|
||||
here — see the next section.
|
||||
so a persistent condition logged loudly is 1440 identical journal lines a day.
|
||||
`_trigger` emits the single WARNING that matters: it is firing *now* on a
|
||||
non-phone alarm time.
|
||||
|
||||
## The morning_routine orchestrator lives elsewhere
|
||||
|
||||
`morning_routine._orchestrator` (in `testsAndMisc/python_pkg/morning_routine/`)
|
||||
runs this package and `screen_locker` as two sequential subprocesses. When
|
||||
either package is extracted to its own repo, **the orchestrator's module
|
||||
reference must be updated in the same change** — this exact mistake
|
||||
(orchestrator left pointing at `python_pkg.screen_locker.screen_lock` after
|
||||
screen-locker's extraction on 2026-05-28) caused a month-long silent
|
||||
production failure where the alarm fired and dismissed correctly but the
|
||||
workout lock crashed with `ModuleNotFoundError` on every run. Once both
|
||||
`wake_alarm` and `screen_locker` are pip-installed system-wide, the
|
||||
orchestrator needs no `PYTHONPATH`/`cwd` plumbing — plain
|
||||
`subprocess.run([sys.executable, "-m", module, "--production"])` resolves
|
||||
both.
|
||||
is what `_trigger` starts, and it now runs one leg: `screen_locker.screen_lock`.
|
||||
Its alarm leg was removed in the same change that deleted `_alarm.py`.
|
||||
|
||||
## Production dependency installation — read this before adding any dependency
|
||||
**When this package's module paths change, the orchestrator must change in the
|
||||
same commit.** Leaving it pointing at a module that no longer exists caused a
|
||||
month-long silent production failure once already (2026-05-28, orchestrator
|
||||
still on `python_pkg.screen_locker.screen_lock` after screen-locker's
|
||||
extraction): the alarm fired and dismissed correctly while the workout lock
|
||||
crashed with `ModuleNotFoundError` on every run.
|
||||
|
||||
`wake-alarm.service` runs `/usr/bin/python` directly — **not** a venv. Any
|
||||
new non-stdlib dependency (this package itself, `gatelock`, `python-kasa`,
|
||||
anything added later) must be installed into system Python's *user*
|
||||
## Production dependency installation
|
||||
|
||||
`wake-alarm-trigger.service` runs `/usr/bin/python` directly — **not** a venv.
|
||||
Any new non-stdlib dependency must go into system Python's *user*
|
||||
site-packages:
|
||||
|
||||
```bash
|
||||
/usr/bin/python3 -m pip install --user --break-system-packages -e .
|
||||
```
|
||||
|
||||
**Always run `install.sh` (or `pip install -e`) from a durable clone, not a
|
||||
scratch directory.** `install.sh` does `pip install -e "$REPO_DIR"` —
|
||||
editable, so a later `git pull` in that same clone updates the running
|
||||
production code with no reinstall needed. The clone must live somewhere
|
||||
permanent (this repo's convention: `~/wake-alarm`, mirroring `~/screen-locker`
|
||||
for the screen-locker package) — if you `pip install -e` from `/tmp/...` or
|
||||
run `pip install "wake_alarm @ git+https://..."` as a one-off, you get a
|
||||
non-editable snapshot frozen at that commit, and the next `git push` here
|
||||
silently does **not** reach the running service.
|
||||
**Run `install.sh` (or `pip install -e`) from a durable clone**, not a scratch
|
||||
directory — it is editable, so a later `git pull` in that clone updates the
|
||||
running production code. From `/tmp` or via `pip install "wake_alarm @ git+..."`
|
||||
you get a snapshot frozen at that commit, and the next push silently does not
|
||||
reach the running service. Convention: `~/wake-alarm`, mirroring
|
||||
`~/screen-locker`.
|
||||
|
||||
`install.sh` already does this. **If you add a dependency and only install it
|
||||
into a dev venv, the production service will silently fail with
|
||||
`ModuleNotFoundError` on its next run** — this exact gap caused a 3-day
|
||||
diet_guard production outage (2026-06-19 to 2026-06-22) for the sibling
|
||||
`gatelock` migration. Always verify against
|
||||
`/usr/bin/python3 -c "import <new_dep>"`, not just the dev venv, before
|
||||
considering a dependency change done.
|
||||
If you add a dependency and only install it into a dev venv, the service fails
|
||||
with `ModuleNotFoundError` on its next run — this exact gap caused a 3-day
|
||||
diet_guard outage. Verify against `/usr/bin/python3 -c "import <dep>"`.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
`uninstall.sh`, never `rm -rf` on the repo. `/usr/local/bin/shutdown` is
|
||||
root-owned and shadows `/usr/bin/shutdown`; deleting the repo leaves it in
|
||||
place, and with the alarm cache gone it falls through to
|
||||
`sudo rtcwake … ; exec systemctl hibernate` — so `shutdown now` would silently
|
||||
stop being a poweroff, every day, forever.
|
||||
|
||||
**`/etc/sudoers.d/wake-alarm` is deliberately left in place** even by a full
|
||||
uninstall: screen-locker's own `_wake_alarm.py:_schedule_rtcwake` still shells
|
||||
out to `sudo rtcwake`, and removing the entry here breaks a live feature in
|
||||
another repo with no error message anywhere.
|
||||
|
||||
## Operational gotchas
|
||||
|
||||
- **`python-kasa` is optional at runtime** (`_smart_plug.py` catches
|
||||
`ImportError` and disables smart-plug control with a warning log), but it
|
||||
*is* a hard dependency for this repo's own tooling (mypy/pylint/tests need
|
||||
it importable) — see `pyproject.toml`/`requirements.txt`.
|
||||
- **The `wave` module needs special pylint handling.** `_audio.py` opens WAV
|
||||
files in write mode; pylint's stdlib stub infers the read-mode overload and
|
||||
wrongly flags `setnchannels`/`setsampwidth`/`setframerate`/`writeframes` as
|
||||
missing. See the `generated-members` list in `pyproject.toml`'s
|
||||
`[tool.pylint.typecheck]` — don't remove it if pylint starts complaining
|
||||
about `wave.Wave_write`.
|
||||
- **`wake_state.json` is runtime state, not tracked.** It holds the
|
||||
HMAC-signed dismissal record for the current alarm day, was accidentally
|
||||
committed in the monorepo, and must stay gitignored here.
|
||||
HMAC-signed once-per-day latch. It was accidentally committed in the
|
||||
monorepo and must stay gitignored here.
|
||||
- **`WAKE_STATE_FILE` is redirected in `tests/conftest.py`, not per-file.**
|
||||
`_trigger` writes it now, and the trigger tests never redirected it — a
|
||||
per-file redirect is exactly the kind that gets forgotten when a new writer
|
||||
appears, and the failure is silent: a green suite that quietly stamped the
|
||||
live state file.
|
||||
|
||||
## Commands
|
||||
|
||||
- Run tests: `python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100`
|
||||
- Phone tests: `cd phone_app && flutter test && flutter analyze`
|
||||
- Lint: `pre-commit run --all-files`
|
||||
- Test the lock manually (safe, closeable): `python -m wake_alarm._alarm --demo`
|
||||
- Show the resolved alarm: `python -m wake_alarm._sync --show` (add
|
||||
`--no-cache` to force a live GitHub read)
|
||||
- Show the resolved alarm: `python -m wake_alarm._sync --show` (`--no-cache` forces a live read)
|
||||
- Show what the next tick would do: `python -m wake_alarm._trigger --dry-run`
|
||||
- Watch it live: `journalctl --user -fu wake-alarm-trigger.service`
|
||||
- Install for production: `bash install.sh`
|
||||
- Install: `bash install.sh` — Uninstall: `bash uninstall.sh`
|
||||
- Gate the shell/system scripts: `bash scripts/vmbox/harness.sh`
|
||||
(disposable Arch VM; coverage and limits in **`DOCS-vmbox.md`** — notably
|
||||
*not* audio, the phone, or the bootloader).
|
||||
(disposable Arch VM; see **`DOCS-vmbox.md`**)
|
||||
- Ring the phone for a manual test: `lib/main_ring_probe.dart` (see
|
||||
`DOCS-phone-alarm.md` — it starts a **real** alarm, and the new sequence is
|
||||
what stops it)
|
||||
|
||||
The dev venv lacks `gatelock`, so anything importing `_state` (including
|
||||
`_trigger`) needs `/usr/bin/python3`, not `.venv/bin/python`.
|
||||
|
||||
## Do NOT
|
||||
|
||||
- Don't add a dependency without doing the production install-path check
|
||||
above. `_sync.py`'s **GitHub** path is stdlib-only for exactly this reason —
|
||||
don't "improve" it to `requests`, and don't make the `crdt_sync` import
|
||||
unconditional: the `try/except ImportError` guard is what keeps a failed
|
||||
install from killing the trigger.
|
||||
- Don't forget the orchestrator when changing this package's module path —
|
||||
see "The morning_routine orchestrator lives elsewhere" above.
|
||||
- Don't give `wake-alarm.service` an `[Install]` section: it fires at login
|
||||
time, not alarm time.
|
||||
- Don't point the trigger at `wake-alarm.service`; it must start
|
||||
`morning-routine.service`, or the workout lock never runs.
|
||||
- Don't raise the log level of `_sync`'s degraded-read paths: the per-minute
|
||||
tick turns any WARNING there into 1440 journal lines a day.
|
||||
- Don't add a dependency without the production install-path check above.
|
||||
`_sync.py`'s GitHub path is stdlib-only for that reason — don't "improve" it
|
||||
to `requests`, and don't make the `crdt_sync` import unconditional.
|
||||
- Don't forget the orchestrator when changing this package's module paths.
|
||||
- Don't point the trigger at anything but `morning-routine.service`, or the
|
||||
workout lock never runs.
|
||||
- Don't remove the trigger's once-per-day latch.
|
||||
- Don't raise the log level of `_sync`'s degraded-read paths.
|
||||
- Don't let the bypass skip the walk, and don't add a challenge kind that
|
||||
hides its own prompt.
|
||||
- Don't delete `/etc/sudoers.d/wake-alarm`.
|
||||
- Don't commit `wake_state.json`.
|
||||
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -28,42 +28,21 @@ those wrong on the host is not a failed test, it is a broken machine.
|
||||
| Case | What a pass means |
|
||||
|---|---|
|
||||
| `imports-from-system-python` | `wake_alarm` and `gatelock` import under `/usr/bin/python3`, the interpreter the units really use — not a venv |
|
||||
| `trigger-timer-enabled` | `wake-alarm-trigger.timer` is enabled after `install.sh` |
|
||||
| `alarm-service-never-auto-starts` | `wake-alarm.service` is still `static`. An `[Install]` section there fires the alarm at *login*, because `_should_run_alarm()` has no time gate |
|
||||
| `shutdown-wrapper-shadows-real-shutdown` | `shutdown` resolves to `/usr/local/bin/shutdown`, ahead of `/usr/bin/shutdown` |
|
||||
| `wrapper-parses-the-alarm-cache` | `jq` reads the `{"hour","minute","fetched_at"}` shape the wrapper expects — the only coupling left between it and `_sync.py`'s `_write_cache()` |
|
||||
| `trigger-timer-enabled` | `wake-alarm-trigger.timer` is enabled after `install.sh`. Writing the unit file is not the same as systemd running it, and a timer that is present but disabled fails silently every morning |
|
||||
| `trigger-dry-run` | one tick reaches a decision without crashing |
|
||||
| `inhibitor-never-claims-a-block-it-lacks` | see the caveat below — this is *not* "the power button was blocked" |
|
||||
| `cut-short-alarm-refires` | an unfinished session record makes the trigger fire even with the catch-up window long closed |
|
||||
| `alarm-window-opens` (`--window`) | the fullscreen Tk window really paints, on a real X server, verified by a host-side screenshot |
|
||||
|
||||
`install.sh` itself running to completion is a precondition of all of them: if
|
||||
it fails, the harness stops with exit 2 and dumps the log.
|
||||
|
||||
## What it cannot cover — do not read a pass as covering these
|
||||
|
||||
- **Audio.** The guest has no `pactl` and no `paplay`. Every sink, card-profile
|
||||
and reactivation path — the whole of item 6 — is untestable here and was
|
||||
verified directly on the host instead.
|
||||
- **That the power button is actually blocked.** The guest has no physical
|
||||
power button, *and* its polkit refuses an inhibit from a non-interactive ssh
|
||||
session outright (`Failed to inhibit: Access denied`). So the case asserts
|
||||
something narrower but still useful: that the code never *claims* a block it
|
||||
did not get. `REFUSED` is a passing outcome.
|
||||
- **The phone.** Nothing about the Flutter app, the sync round-trip, or the
|
||||
alarm the phone rings.
|
||||
- **GPU and the real display outputs.** The guest's X server is not DP-2 and
|
||||
HDMI-0, so multi-monitor surface placement proves nothing here.
|
||||
- **Anything bootloader-related.** The guest is BIOS/GRUB; this machine is
|
||||
UEFI. A guest pass on boot-adjacent work is actively misleading — a sibling
|
||||
repo's `boot-repair` passes in the guest while deleting the only bootable
|
||||
kernel on a UEFI host.
|
||||
- **rtcwake actually waking the machine.** `rtcwake` exists in the guest, but a
|
||||
VM's RTC alarm is not the host's firmware.
|
||||
|
||||
**A sandbox pass never proves the host is fine.** It is an added first step:
|
||||
sandbox → report → host run. Phrase results as "passed in vmbox, not verified
|
||||
on the host".
|
||||
- **The phone.** The entire dismiss sequence — two NFC tags, the step counter,
|
||||
the challenges, the 5-minute bypass — lives in `phone_app/` and is verified
|
||||
on the Pixel 6a, never here. This harness knows nothing about it.
|
||||
- **The bootloader.** The guest is BIOS/GRUB, this PC is UEFI. Any
|
||||
firmware/ESP work goes straight to the host.
|
||||
- **A green run is not host verification.** Report it as "passed in vmbox, not
|
||||
verified on the host", always.
|
||||
|
||||
## Guest provisioning, and why
|
||||
|
||||
@ -89,7 +68,7 @@ approximation, so a break in `install.sh` fails the gate.
|
||||
makes rsync write into `/home` and fail with permission errors that look
|
||||
like a guest problem.
|
||||
|
||||
## Cases this VM found that the host could not
|
||||
## Cases this VM found that the host could not (historical)
|
||||
|
||||
Both were live bugs, both passed the unit suite:
|
||||
|
||||
|
||||
78
README.md
78
README.md
@ -1,52 +1,78 @@
|
||||
# wake_alarm
|
||||
|
||||
A phone-driven wake alarm for an always-on PC. The alarm time is whatever the
|
||||
companion phone app last synced (via the shared `crdt_sync` transport over a
|
||||
private GitHub repo); a per-minute systemd user timer fires at exactly that
|
||||
time, showing a fullscreen, challenge-dismissed alarm with escalating audio
|
||||
and optional TP-Link Tapo P110 smart-plug control.
|
||||
**The phone is the alarm.** `phone_app/` arms and rings it, and getting past it
|
||||
means walking between two NFC tags and answering three questions. This repo's
|
||||
Python half no longer wakes anyone: all it does is read the alarm time the
|
||||
phone published and start the morning routine (the workout lock) at that time.
|
||||
|
||||
Change the alarm on the phone at any hour and the PC follows within ~5
|
||||
minutes. If the PC does happen to be powered down, a `shutdown` wrapper
|
||||
hibernates with `rtcwake` set to the same synced time, as a fallback.
|
||||
## The dismiss sequence
|
||||
|
||||
Strict, ordered, and latched — every stage required, and a stage once passed
|
||||
never reverts:
|
||||
|
||||
```
|
||||
tag 1 -> 25 steps + 2 min -> tag 2 -> 3 challenges
|
||||
```
|
||||
|
||||
The two tags must be *distinct* and live in different rooms; the same id in
|
||||
both slots is refused at registration. Answers are cumulative, so a wrong one
|
||||
costs the attempt and nothing else.
|
||||
|
||||
**One fallback, deliberately narrow.** After 5 minutes of ringing — or
|
||||
immediately if NFC is off or fewer than two tags are registered — the two *tag*
|
||||
stages are skipped and the challenge count rises to 6. The walk is never
|
||||
skipped. Without a hatch of some kind, a dead tag at 06:00 leaves an alarm that
|
||||
cannot be switched off at all.
|
||||
|
||||
Details, and the things that are a hard crash or a silent no-ring:
|
||||
**`DOCS-phone-alarm.md`**.
|
||||
|
||||
## The PC half
|
||||
|
||||
A per-minute systemd user timer (`wake-alarm-trigger.timer`) reads the alarm
|
||||
the phone published through the shared `crdt_sync` transport, and at that time
|
||||
starts `morning-routine.service`, which runs the workout lock. It records a
|
||||
once-per-day latch so the rest of the catch-up window does not restart it.
|
||||
|
||||
That is all it does. There is no alarm window, no audio, no smart plug, no
|
||||
`rtcwake`, and no `shutdown` wrapper.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bash install.sh
|
||||
bash install.sh # trigger timer
|
||||
bash uninstall.sh # removes it, and any retired waking machinery
|
||||
```
|
||||
|
||||
This installs the package + dependencies into system Python's user
|
||||
site-packages (the systemd units run `/usr/bin/python` directly, not a venv —
|
||||
see `CLAUDE.md`), installs and enables the per-minute trigger timer, the
|
||||
systemd-sleep resume hook, the `shutdown` wrapper that hibernates with
|
||||
`rtcwake` as a fallback, and (optionally) `python-kasa` for smart-plug
|
||||
control.
|
||||
`install.sh` installs the package into system Python's user site-packages (the
|
||||
units run `/usr/bin/python` directly, not a venv — see `CLAUDE.md`) and enables
|
||||
the timer. It also clears the retired pieces an older install left behind,
|
||||
notably `/usr/local/bin/shutdown`, which is root-owned and would otherwise keep
|
||||
turning `shutdown now` into a hibernate forever.
|
||||
|
||||
Reading the phone's alarm needs a GitHub token with `repo` scope, at
|
||||
`~/.config/wake_alarm/sync.json` as `{"token": "..."}`. If that file is
|
||||
absent, a sibling app's `~/.config/{diet_guard,screen_locker}/sync_token` is
|
||||
used instead, so one token covers every app syncing through `kuhyx/syncs`.
|
||||
Reading the phone's alarm uses the shared `~/.config/crdt-sync/firebase.json`.
|
||||
The GitHub mirror (fallback only) needs a `repo`-scoped token at
|
||||
`~/.config/wake_alarm/sync.json` as `{"token": "..."}`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
python -m wake_alarm._alarm --demo # test the alarm window (safe, closeable)
|
||||
python -m wake_alarm._sync --show # what alarm time the PC resolves
|
||||
python -m wake_alarm._sync --show # what alarm time the PC resolves
|
||||
python -m wake_alarm._trigger --dry-run # what the next tick would do
|
||||
journalctl --user -fu wake-alarm-trigger.service # watch it live
|
||||
```
|
||||
|
||||
The alarm fires automatically once installed; no manual invocation is needed
|
||||
in normal operation.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
python -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/pre-commit install && .venv/bin/pre-commit install --hook-type pre-push
|
||||
.venv/bin/python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100
|
||||
cd phone_app && flutter test && flutter analyze
|
||||
```
|
||||
|
||||
See `CLAUDE.md` for scheduling details, the hibernate/`rtcwake` mechanism,
|
||||
the cross-repo `workout_log.json` read, and production deployment gotchas.
|
||||
The dev venv lacks `gatelock`, so anything importing `_state` (including
|
||||
`_trigger`) needs `/usr/bin/python3`, not `.venv/bin/python`.
|
||||
|
||||
See `CLAUDE.md` for the sync resolution chain, the cross-repo coupling to
|
||||
`morning_routine`, and production deployment gotchas.
|
||||
|
||||
@ -25,11 +25,12 @@ starting; this file deliberately does not duplicate it.
|
||||
|
||||
## Remaining, in order
|
||||
|
||||
1. **Item 3 leftover: the QR scanner UI only.** `mobile_scanner`, feeding
|
||||
`RingScreen.onQrScanned`, which already exists and is unit-tested. The
|
||||
payload is registered in Settings → "Room proof" and persisted. NFC and the
|
||||
steps fallback both work, so the alarm is dismissable without this — QR is
|
||||
the third mechanism, not a missing one.
|
||||
1. ~~QR scanner UI~~ — **cancelled 2026-08-30.** QR was deleted rather than
|
||||
finished: it had never been reachable at ring time (no scanner package, no
|
||||
CAMERA permission, `onQrScanned` had no caller), and a printed code is
|
||||
photographable, so completing it would have added a bypass to the very gate
|
||||
the same change was hardening. The dismiss path is now a strict sequence:
|
||||
tag 1 → 25 steps + 2 min → tag 2 → 3 challenges.
|
||||
2. **Item 8, mutual gating.** Phone half is now in place. This is the phase
|
||||
that legitimately touches `_sync.py`, so it carries that split (see below).
|
||||
3. **Item 13, black screen.** Different repo (`~/testsAndMisc/
|
||||
@ -118,8 +119,9 @@ Two harness traps found while testing this, worth keeping:
|
||||
|
||||
### Still unverified
|
||||
|
||||
- The **QR** proof has no scanner UI (`mobile_scanner`); `checkQr` and its
|
||||
persistence are unit-tested only.
|
||||
- The **two-tag sequence** has not been walked end to end on the phone: only
|
||||
one physical tag is registered, so every on-device run so far has fallen
|
||||
through to the 5-minute bypass. Needs a second tag.
|
||||
- The **locked-keyguard** dismiss was not run to completion: the confirmed run
|
||||
was on an unlocked phone. The PIN-prompt path is implemented but unproven.
|
||||
|
||||
|
||||
176
install.sh
176
install.sh
@ -1,29 +1,31 @@
|
||||
#!/bin/bash
|
||||
# Install the weekend wake alarm systemd user service and sudoers entry.
|
||||
# Install the wake-alarm trigger timer.
|
||||
#
|
||||
# Usage: bash install.sh
|
||||
#
|
||||
# The PC no longer wakes anyone -- the phone does. All this installs is the
|
||||
# per-minute trigger that starts morning-routine.service (and therefore the
|
||||
# workout lock) at the time the phone last synced.
|
||||
#
|
||||
# What it does:
|
||||
# 1. Installs wake_alarm + dependencies for /usr/bin/python
|
||||
# 2. Installs system dependencies (alsa-utils, ddcutil)
|
||||
# 3. Installs the systemd user units and enables the per-minute trigger timer
|
||||
# (which fires the alarm at the time the phone synced)
|
||||
# 4. Installs the systemd-sleep hook (restarts alarm after hibernate resume)
|
||||
# 5. Adds a sudoers entry for passwordless rtcwake
|
||||
# 6. Installs shutdown wrapper so "shutdown now" also hibernates on alarm nights
|
||||
# 7. Removes the retired fan-control script + its sudoers entry
|
||||
# 8. Installs python-kasa (AUR) so the alarm can toggle a Tapo P110 smart plug
|
||||
# 9. Installs ddcutil and grants /dev/i2c-* access for DDC/CI monitor control
|
||||
# 2. Installs the systemd user units and enables the per-minute trigger timer
|
||||
# 3. Removes the retired waking machinery from earlier installs
|
||||
#
|
||||
# Deliberately NOT done any more, and why:
|
||||
# - no shutdown wrapper, no rtcwake, no sleep hook: the PC does not wake you
|
||||
# - the /etc/sudoers.d/wake-alarm entry is left ALONE. screen-locker's own
|
||||
# _wake_alarm.py still shells out to `sudo rtcwake`, so removing it here
|
||||
# would silently break a live feature in another repo.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Refuse to run under sudo. This script must run as the logged-in user: it
|
||||
# installs *user* systemd units and calls `sudo` itself for the few root-owned
|
||||
# pieces. Under sudo, XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS are stripped,
|
||||
# so `systemctl --user` fails at step 3 and every later step -- including the
|
||||
# shutdown wrapper at step 6 -- is silently skipped while the earlier pip output
|
||||
# still looks like success. It also runs pip as root, which leaves root-owned
|
||||
# files in ~/.cache/pip. Failing loudly here beats a half-finished install.
|
||||
# so `systemctl --user` fails and later steps are silently skipped while the
|
||||
# earlier pip output still looks like success. It also runs pip as root, which
|
||||
# leaves root-owned files in ~/.cache/pip. Failing loudly beats half-finishing.
|
||||
if [[ -n "${SUDO_USER:-}" || $EUID -eq 0 ]]; then
|
||||
echo "Error: do not run this script with sudo." >&2
|
||||
echo "Run it as your normal user: bash $0" >&2
|
||||
@ -35,139 +37,35 @@ fi
|
||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
readonly SCRIPT_DIR
|
||||
readonly REPO_DIR="$SCRIPT_DIR"
|
||||
SERVICE_FILE="$SCRIPT_DIR/wake-alarm.service"
|
||||
TRIGGER_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-trigger.service"
|
||||
TRIGGER_TIMER_SRC="$SCRIPT_DIR/wake-alarm-trigger.timer"
|
||||
SLEEP_HOOK_SRC="$SCRIPT_DIR/sleep-hook.sh"
|
||||
SHUTDOWN_WRAPPER_SRC="$SCRIPT_DIR/shutdown-wrapper.sh"
|
||||
FANS_SCRIPT_DST="/usr/local/bin/wake-alarm-fans.sh"
|
||||
SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
|
||||
SLEEP_HOOK_DST="/usr/lib/systemd/system-sleep/wake-alarm.sh"
|
||||
SHUTDOWN_WRAPPER_DST="/usr/local/bin/shutdown"
|
||||
SUDOERS_FILE="/etc/sudoers.d/wake-alarm"
|
||||
RTCWAKE_BIN="/usr/sbin/rtcwake"
|
||||
readonly TRIGGER_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-trigger.service"
|
||||
readonly TRIGGER_TIMER_SRC="$SCRIPT_DIR/wake-alarm-trigger.timer"
|
||||
readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
|
||||
|
||||
echo "=== Weekend Wake Alarm Installer ==="
|
||||
echo "=== Wake Alarm Trigger Installer ==="
|
||||
|
||||
# 1. Install this package + its dependencies into system Python -------------
|
||||
echo "[1/9] Installing wake_alarm + dependencies for /usr/bin/python..."
|
||||
echo "[1/3] Installing wake_alarm for /usr/bin/python..."
|
||||
# Editable, and from this clone: a later `git pull` here then updates the
|
||||
# running production code with no reinstall. A non-editable snapshot taken
|
||||
# from a scratch directory silently stops tracking this repo.
|
||||
/usr/bin/python3 -m pip install --user --break-system-packages -e "$REPO_DIR"
|
||||
echo " Installed. Verifying import..."
|
||||
/usr/bin/python3 -c "import wake_alarm; import gatelock" \
|
||||
&& echo " wake_alarm and gatelock import cleanly from the system interpreter."
|
||||
/usr/bin/python3 -c "import wake_alarm; import gatelock"
|
||||
echo " wake_alarm and gatelock import under /usr/bin/python."
|
||||
|
||||
# 2. Install system dependencies
|
||||
echo "[2/9] Checking system dependencies..."
|
||||
if ! command -v speaker-test &>/dev/null; then
|
||||
echo " Installing alsa-utils (required for speaker-test)..."
|
||||
sudo pacman -S --noconfirm alsa-utils
|
||||
else
|
||||
echo " alsa-utils already installed"
|
||||
fi
|
||||
|
||||
# 3. Install systemd user units (alarm + the trigger timer that fires it)
|
||||
echo "[3/9] Installing systemd user units..."
|
||||
echo "[2/3] Installing systemd user units..."
|
||||
mkdir -p "$SYSTEMD_USER_DIR"
|
||||
cp "$SERVICE_FILE" "$SYSTEMD_USER_DIR/wake-alarm.service"
|
||||
cp "$TRIGGER_SERVICE_SRC" "$SYSTEMD_USER_DIR/wake-alarm-trigger.service"
|
||||
cp "$TRIGGER_TIMER_SRC" "$SYSTEMD_USER_DIR/wake-alarm-trigger.timer"
|
||||
cp "$TRIGGER_SERVICE_SRC" "$TRIGGER_TIMER_SRC" "$SYSTEMD_USER_DIR/"
|
||||
systemctl --user daemon-reload
|
||||
echo " Installed wake-alarm.service, wake-alarm-trigger.{service,timer}"
|
||||
|
||||
# wake-alarm.service must NOT auto-start: the PC is always on, so
|
||||
# graphical-session.target is reached on every login and the alarm has no time
|
||||
# gate — it would fire at login time. The timer owns firing (via
|
||||
# morning-routine.service). Both guards matter under `set -euo pipefail`:
|
||||
# a unit with no [Install] can't be enabled, and disabling a never-enabled unit
|
||||
# can also return non-zero.
|
||||
systemctl --user disable wake-alarm.service 2>/dev/null || true
|
||||
echo " wake-alarm.service left disabled (started only by the trigger)"
|
||||
|
||||
systemctl --user enable --now wake-alarm-trigger.timer
|
||||
echo " Trigger timer enabled and started (ticks once a minute)"
|
||||
echo " Trigger timer enabled and started (ticks once a minute)."
|
||||
|
||||
# 4. Install systemd-sleep hook (restarts alarm after hibernate resume)
|
||||
echo "[4/9] Installing systemd-sleep hook..."
|
||||
sudo cp "$SLEEP_HOOK_SRC" "$SLEEP_HOOK_DST"
|
||||
sudo chmod 0755 "$SLEEP_HOOK_DST"
|
||||
echo " Installed to $SLEEP_HOOK_DST"
|
||||
|
||||
# 5. Add sudoers entry for rtcwake (requires root)
|
||||
echo "[5/9] Setting up sudoers for rtcwake..."
|
||||
SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $RTCWAKE_BIN"
|
||||
if [[ -f "$SUDOERS_FILE" ]] && grep -qF "$SUDOERS_LINE" "$SUDOERS_FILE"; then
|
||||
echo " Sudoers entry already exists"
|
||||
else
|
||||
echo " Adding sudoers entry (requires sudo)..."
|
||||
echo "$SUDOERS_LINE" | sudo tee "$SUDOERS_FILE" > /dev/null
|
||||
sudo chmod 0440 "$SUDOERS_FILE"
|
||||
echo " Added: $SUDOERS_LINE"
|
||||
fi
|
||||
|
||||
# 6. Install shutdown wrapper (/usr/local/bin/shutdown shadows /usr/bin/shutdown)
|
||||
echo "[6/9] Installing shutdown wrapper..."
|
||||
sudo cp "$SHUTDOWN_WRAPPER_SRC" "$SHUTDOWN_WRAPPER_DST"
|
||||
sudo chmod 0755 "$SHUTDOWN_WRAPPER_DST"
|
||||
echo " Installed to $SHUTDOWN_WRAPPER_DST"
|
||||
echo " 'shutdown now' will now hibernate (not poweroff) on alarm nights."
|
||||
|
||||
# 7. Remove the retired fan-control script and its sudoers entry.
|
||||
# The alarm no longer ramps fans: it added anxiety without helping anyone wake
|
||||
# up. An upgrade must actively clean up, or a machine that installed the old
|
||||
# version keeps a passwordless root entry for a script that no longer exists.
|
||||
echo "[7/9] Removing retired fan-control script..."
|
||||
if [[ -e "$FANS_SCRIPT_DST" ]]; then
|
||||
sudo rm -f "$FANS_SCRIPT_DST"
|
||||
echo " Removed $FANS_SCRIPT_DST"
|
||||
fi
|
||||
if [[ -f "$SUDOERS_FILE" ]] && grep -qF "$FANS_SCRIPT_DST" "$SUDOERS_FILE"; then
|
||||
sudo sed -i "\\|$FANS_SCRIPT_DST|d" "$SUDOERS_FILE"
|
||||
sudo chmod 0440 "$SUDOERS_FILE"
|
||||
echo " Dropped the fan NOPASSWD sudoers entry"
|
||||
fi
|
||||
sudo rm -f /run/wake-alarm-fans.state
|
||||
|
||||
# 8. Install python-kasa (AUR) for TP-Link Tapo P110 smart-plug control
|
||||
echo "[8/9] Installing python-kasa (AUR)..."
|
||||
if python -c 'import kasa' 2>/dev/null; then
|
||||
echo " python-kasa already installed"
|
||||
elif command -v yay &>/dev/null; then
|
||||
yay -S --noconfirm --needed python-kasa
|
||||
else
|
||||
echo " WARNING: yay not found; install python-kasa manually for smart-plug support" >&2
|
||||
fi
|
||||
if [[ ! -f "$HOME/.config/wake_alarm/tapo.json" ]]; then
|
||||
echo " NOTE: ~/.config/wake_alarm/tapo.json not found — smart-plug control is disabled."
|
||||
echo " Create it (mode 0600) with keys: host, email, password."
|
||||
fi
|
||||
|
||||
# 9. Install ddcutil for DDC/CI monitor power control
|
||||
# ddcutil lets the alarm force the G27Q on via DDC/CI even when the monitor
|
||||
# was physically powered off (power button), bypassing DPMS limitations.
|
||||
echo "[9/9] Installing ddcutil (DDC/CI monitor power control)..."
|
||||
if command -v ddcutil &>/dev/null; then
|
||||
echo " ddcutil already installed"
|
||||
else
|
||||
sudo pacman -S --noconfirm ddcutil
|
||||
echo " ddcutil installed"
|
||||
fi
|
||||
# ddcutil needs access to /dev/i2c-* — add user to i2c group if it exists.
|
||||
if getent group i2c &>/dev/null; then
|
||||
if ! id -nG "$USER" | grep -qw i2c; then
|
||||
sudo usermod -aG i2c "$USER"
|
||||
echo " Added $USER to i2c group (re-login required for group to take effect)"
|
||||
else
|
||||
echo " $USER already in i2c group"
|
||||
fi
|
||||
else
|
||||
echo " i2c group not found — ddcutil will run via sudo"
|
||||
fi
|
||||
echo "[3/3] Removing retired waking machinery from earlier installs..."
|
||||
bash "$SCRIPT_DIR/uninstall.sh" --retired-only
|
||||
echo
|
||||
|
||||
echo "=== Installation complete ==="
|
||||
echo "The trigger timer ticks once a minute and fires the alarm at the time"
|
||||
echo "the phone last synced (needs ~/.config/wake_alarm/sync.json with a"
|
||||
echo "repo-scoped GitHub token — see MIGRATION.md)."
|
||||
echo "Check what it resolves right now: python -m wake_alarm._sync --show"
|
||||
echo "If the PC is powered down instead, the shutdown wrapper hibernates with"
|
||||
echo "rtcwake set to the same alarm, and the sleep hook runs it on resume."
|
||||
echo "To test now: python -m wake_alarm._alarm --demo"
|
||||
echo "The timer ticks once a minute and starts morning-routine.service at the"
|
||||
echo "time the phone last synced."
|
||||
echo "Check what it resolves right now: python -m wake_alarm._sync --show"
|
||||
echo "Check what the next tick would do: python -m wake_alarm._trigger --dry-run"
|
||||
echo "Watch it live: journalctl --user -fu wake-alarm-trigger.service"
|
||||
|
||||
@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "wake-alarm"
|
||||
version = "1.0.0"
|
||||
description = "Phone-driven wake alarm with fan ramp and smart-plug control"
|
||||
description = "Trigger that starts the morning routine at the phone's alarm time"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.2#subdirectory=gatelock",
|
||||
@ -13,7 +13,6 @@ dependencies = [
|
||||
# falls back to its stdlib GitHub path if the import fails, so a broken
|
||||
# install degrades the read instead of killing the per-minute trigger.
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
|
||||
"python-kasa==0.10.2",
|
||||
]
|
||||
|
||||
# Package discovery must be explicit: phone_app/ is a Flutter app, not a Python
|
||||
@ -130,10 +129,6 @@ disable = [
|
||||
"use-implicit-booleaness-not-comparison",
|
||||
"use-implicit-booleaness-not-comparison-to-string",
|
||||
"use-implicit-booleaness-not-comparison-to-zero",
|
||||
# consider-using-with (R1732): several subprocess.Popen calls are
|
||||
# intentionally fire-and-forget — the process must outlive the calling
|
||||
# scope and is polled/killed later, so a `with` block is wrong here.
|
||||
"consider-using-with",
|
||||
]
|
||||
|
||||
[tool.pylint.design]
|
||||
@ -143,9 +138,6 @@ max-attributes = 10
|
||||
|
||||
[tool.pylint.typecheck]
|
||||
# unittest.mock.MagicMock generates assertion/introspection methods at runtime.
|
||||
# wave.open(path, "wb") returns a Wave_write, but pylint's stdlib stub infers the
|
||||
# read-mode Wave_read overload and wrongly reports its setter/writer methods as
|
||||
# missing — list them so the write API is recognised.
|
||||
generated-members = [
|
||||
".*\\.assert_called_once_with",
|
||||
".*\\.assert_called_once",
|
||||
@ -155,11 +147,6 @@ generated-members = [
|
||||
".*\\.call_args",
|
||||
".*\\.call_args_list",
|
||||
".*\\.call_count",
|
||||
".*\\.setnchannels",
|
||||
".*\\.setsampwidth",
|
||||
".*\\.setframerate",
|
||||
".*\\.writeframes",
|
||||
".*\\.writeframesraw",
|
||||
]
|
||||
|
||||
[tool.bandit]
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
# Wake Alarm — runtime + development dependencies
|
||||
# Runtime: tkinter/json/hmac/wave/subprocess (stdlib) plus gatelock,
|
||||
# crdt-sync and python-kasa below. crdt-sync and python-kasa are both
|
||||
# optional at runtime — their modules degrade gracefully if missing (the
|
||||
# alarm read falls back to GitHub, smart-plug control disables itself) — but
|
||||
# both are required here for mypy/pylint/tests.
|
||||
# Runtime: json/hmac/subprocess (stdlib) plus gatelock and crdt-sync below.
|
||||
# crdt-sync is optional at runtime — the alarm read falls back to GitHub if
|
||||
# it is missing — but it is required here for mypy/pylint/tests.
|
||||
bandit==1.9.4
|
||||
codespell==2.4.3
|
||||
coverage==7.16.0
|
||||
@ -17,5 +15,4 @@ pytest-cov==7.1.0
|
||||
pytest-randomly==4.1.0
|
||||
pytest-sugar==1.1.1
|
||||
pytest-xdist==3.8.0
|
||||
python-kasa==0.10.2
|
||||
ruff==0.16.5
|
||||
|
||||
@ -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())
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -15,10 +15,6 @@
|
||||
set -uo pipefail
|
||||
|
||||
readonly PY=/usr/bin/python3
|
||||
# The helper .py files ship beside this script, so each gets real linting
|
||||
# and type checking instead of hiding inside a heredoc.
|
||||
CASE_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
readonly CASE_DIR
|
||||
FAILURES=0
|
||||
|
||||
pass() {
|
||||
@ -42,9 +38,9 @@ case_imports() {
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Case: units land in the right enablement state --------------------------
|
||||
# wake-alarm.service must stay `static`. An [Install] section there fires the
|
||||
# alarm at *login* time, because _should_run_alarm() has no time gate.
|
||||
# --- Case: the trigger timer is actually enabled -----------------------------
|
||||
# install.sh writing the unit file is not the same as systemd running it, and
|
||||
# a timer that is present but disabled fails silently every morning.
|
||||
case_units() {
|
||||
local name state
|
||||
name="trigger-timer-enabled"
|
||||
@ -54,42 +50,6 @@ case_units() {
|
||||
else
|
||||
fail "$name" "expected enabled, got '$state'"
|
||||
fi
|
||||
|
||||
name="alarm-service-never-auto-starts"
|
||||
state="$(systemctl --user is-enabled wake-alarm.service 2>&1)"
|
||||
if [[ "$state" == "static" || "$state" == "disabled" ]]; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "wake-alarm.service is '$state'; it must never auto-start"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Case: the shutdown wrapper shadows /usr/bin/shutdown --------------------
|
||||
case_shutdown_wrapper() {
|
||||
local name="shutdown-wrapper-shadows-real-shutdown"
|
||||
local resolved
|
||||
resolved="$(command -v shutdown)"
|
||||
if [[ "$resolved" == "/usr/local/bin/shutdown" ]]; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "shutdown resolves to '$resolved', not the wrapper"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Case: the wrapper reads the alarm cache, not the network ----------------
|
||||
# It parses ~/.config/wake_alarm/alarm_cache.json with jq. The shape is the
|
||||
# only coupling left between the wrapper and _sync.py's _write_cache().
|
||||
case_wrapper_reads_cache() {
|
||||
local name="wrapper-parses-the-alarm-cache"
|
||||
local cache="$HOME/.config/wake_alarm/alarm_cache.json"
|
||||
mkdir -p "$(dirname "$cache")"
|
||||
printf '{"hour": 6, "minute": 45, "fetched_at": %d}\n' "$(date +%s)" > "$cache"
|
||||
if jq -e '.hour == 6 and .minute == 45' "$cache" >/dev/null 2>&1; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "jq could not read the cache shape the wrapper expects"
|
||||
fi
|
||||
rm -f "$cache"
|
||||
}
|
||||
|
||||
# --- Case: the trigger decides without crashing ------------------------------
|
||||
@ -102,80 +62,15 @@ case_trigger_dry_run() {
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Case: the inhibitor reports honestly ------------------------------------
|
||||
# The guest's polkit denies an inhibit from a non-interactive ssh session
|
||||
# ("Failed to inhibit: Access denied", exit 1), so what is asserted here is
|
||||
# NOT that the power key gets blocked -- it is that the code never *claims*
|
||||
# a block it did not get. That distinction is the bug this case found: Popen
|
||||
# succeeded, the alarm logged "power key inhibited", and nothing was.
|
||||
#
|
||||
# The two accepted outcomes are therefore "took it and released it" and
|
||||
# "logind refused and we said so". A silent third state is the failure.
|
||||
# See DOCS-vmbox.md for why the block itself cannot be proven in a guest.
|
||||
case_inhibitor() {
|
||||
local name="inhibitor-never-claims-a-block-it-lacks"
|
||||
"$PY" "$CASE_DIR/check_inhibitor.py" > /tmp/inhibit_result 2>/dev/null
|
||||
local result
|
||||
result="$(cat /tmp/inhibit_result 2>/dev/null)"
|
||||
case "$result" in
|
||||
"HELD FREED"|"REFUSED")
|
||||
pass "$name ($result)"
|
||||
;;
|
||||
*)
|
||||
fail "$name" "expected 'HELD FREED' or 'REFUSED', got '$result'"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# --- Case: an unfinished session re-fires --------------------------------
|
||||
# The one case a VM tests better than the host: a real poweroff mid-alarm.
|
||||
# Here we assert the decision the next boot's trigger tick would make.
|
||||
case_refire() {
|
||||
local name="cut-short-alarm-refires"
|
||||
"$PY" "$CASE_DIR/check_refire.py" > /tmp/refire_result 2>/dev/null
|
||||
local result
|
||||
result="$(cat /tmp/refire_result 2>/dev/null)"
|
||||
if [[ "$result" == "False-True-False" ]]; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "expected False-True-False, got '$result'"
|
||||
fi
|
||||
rm -f "$HOME/.config/wake_alarm/alarm_session.json"
|
||||
}
|
||||
|
||||
# --- Case: the alarm window really opens on a real X server ------------------
|
||||
# Verified by the HOST via `vm screenshot`; this half only launches it.
|
||||
case_alarm_window() {
|
||||
local name="alarm-window-opens"
|
||||
"$PY" -m wake_alarm._alarm --demo --trigger-now >/tmp/alarm.log 2>&1 &
|
||||
local pid=$!
|
||||
sleep 12
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
pass "$name"
|
||||
echo "$pid" > /tmp/alarm.pid
|
||||
else
|
||||
fail "$name" "the alarm exited early: $(tail -3 /tmp/alarm.log)"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
case "${1:-all}" in
|
||||
window)
|
||||
# Run alone: it leaves a fullscreen window up for the host to
|
||||
# screenshot, which would hide every later case's output.
|
||||
case_alarm_window
|
||||
;;
|
||||
all)
|
||||
case_imports
|
||||
case_units
|
||||
case_shutdown_wrapper
|
||||
case_wrapper_reads_cache
|
||||
case_trigger_dry_run
|
||||
case_inhibitor
|
||||
case_refire
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 [all|window]" >&2
|
||||
echo "usage: $0 [all]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
@ -11,14 +11,13 @@
|
||||
# What it deliberately cannot cover -- audio, the phone, real outputs, the
|
||||
# bootloader -- is in DOCS-vmbox.md. Read that before trusting a pass.
|
||||
#
|
||||
# Usage: bash scripts/vmbox/harness.sh [--name <sandbox>] [--keep] [--window]
|
||||
# Usage: bash scripts/vmbox/harness.sh [--name <sandbox>] [--keep]
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VM_NAME="wake-alarm-harness"
|
||||
KEEP=0
|
||||
RUN_WINDOW=0
|
||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
readonly SCRIPT_DIR
|
||||
# Absolute and symlink-resolved: `vm share` and `vm repo` both key off the
|
||||
@ -29,11 +28,6 @@ readonly REPO_DIR
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
readonly SCRIPT_NAME
|
||||
|
||||
# Packages the guest image lacks that this package genuinely needs. tk is not
|
||||
# optional: without it `import tkinter` fails with a missing libtk8.6.so and
|
||||
# the alarm cannot open its window. Measured on the stock guest, not guessed.
|
||||
readonly GUEST_PACKAGES="tk"
|
||||
|
||||
# Printed by guest_cases.sh only when every case passed. See run_cases().
|
||||
readonly GUEST_OK_SENTINEL="ALL-CASES-PASSED"
|
||||
|
||||
@ -46,7 +40,6 @@ Run wake_alarm's system-level cases in a disposable Arch VM.
|
||||
Options:
|
||||
--name <sandbox> Sandbox name (default: $VM_NAME)
|
||||
--keep Leave the sandbox running afterwards, for poking at
|
||||
--window Also open the real alarm window and screenshot it
|
||||
-h, --help Show this help
|
||||
|
||||
Exit codes:
|
||||
@ -90,11 +83,6 @@ provision_sandbox() {
|
||||
step "Sharing the repo with the guest"
|
||||
vm share "$REPO_DIR" >/dev/null
|
||||
|
||||
step "Installing guest packages ($GUEST_PACKAGES)"
|
||||
vm run "$VM_NAME" \
|
||||
"sudo pacman -S --noconfirm --needed $GUEST_PACKAGES" >/dev/null ||
|
||||
die "could not install guest packages"
|
||||
|
||||
# --worktree, not a clone: the point of the gate is to test the edits in
|
||||
# front of you, including the ones not committed yet.
|
||||
step "Copying the working tree into the guest"
|
||||
@ -125,21 +113,6 @@ run_cases() {
|
||||
grep -q "$GUEST_OK_SENTINEL" <<<"$output"
|
||||
}
|
||||
|
||||
# The alarm's window needs a real X server, so the guest is the only place it
|
||||
# can be seen at all without hijacking the host's screen. The screenshot is
|
||||
# the artefact: it outlives the guest and is what a human actually checks.
|
||||
run_window_case() {
|
||||
step "Opening the alarm window in the guest"
|
||||
local output
|
||||
output="$(vm run "$VM_NAME" \
|
||||
'bash ~/wake-alarm/scripts/vmbox/guest_cases.sh window' 2>&1)"
|
||||
echo "$output"
|
||||
grep -q "$GUEST_OK_SENTINEL" <<<"$output" || return 1
|
||||
local shot="/tmp/wake-alarm-window.png"
|
||||
vm screenshot "$VM_NAME" "$shot"
|
||||
echo "Alarm window screenshot: $shot"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ $KEEP -eq 1 ]]; then
|
||||
echo "Sandbox '$VM_NAME' left running (--keep). Poke at it with: vm ssh $VM_NAME"
|
||||
@ -157,9 +130,6 @@ main() {
|
||||
|
||||
local status=0
|
||||
run_cases || status=1
|
||||
if [[ $RUN_WINDOW -eq 1 ]]; then
|
||||
run_window_case || status=1
|
||||
fi
|
||||
|
||||
echo "============================================================"
|
||||
if [[ $status -eq 0 ]]; then
|
||||
@ -182,10 +152,6 @@ while [[ $# -gt 0 ]]; do
|
||||
KEEP=1
|
||||
shift
|
||||
;;
|
||||
--window)
|
||||
RUN_WINDOW=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
;;
|
||||
|
||||
@ -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
|
||||
@ -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
100
uninstall.sh
Executable 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 "$@"
|
||||
@ -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.
|
||||
@ -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()
|
||||
@ -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,
|
||||
)
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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,
|
||||
)
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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,
|
||||
)
|
||||
@ -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,
|
||||
),
|
||||
)()
|
||||
@ -1,4 +1,9 @@
|
||||
"""Constants for the weekend wake alarm system."""
|
||||
"""Constants for the wake-alarm trigger and the phone-alarm sync.
|
||||
|
||||
The PC no longer wakes anyone -- the phone does. What is left here is what
|
||||
the per-minute trigger needs to answer two questions: what time did the phone
|
||||
say, and has the morning routine already started today.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -8,14 +13,6 @@ from pathlib import Path
|
||||
# Every day of the week.
|
||||
ALARM_DAYS: frozenset[int] = frozenset({0, 1, 2, 3, 4, 5, 6})
|
||||
|
||||
# How many hours after shutdown the PC should wake.
|
||||
# Only used by the hibernate fallback path (shutdown-wrapper.sh) for the rare
|
||||
# night the PC is actually powered down; the primary path is the per-minute
|
||||
# trigger timer, which reads the phone's alarm directly (see _trigger.py).
|
||||
WAKE_AFTER_HOURS: int = 8
|
||||
|
||||
# Minutes after alarm starts within which you must dismiss to earn skip
|
||||
DISMISS_WINDOW_MINUTES: int = 30
|
||||
|
||||
# Hour at which the alarm fires when the phone's alarm cannot be resolved at
|
||||
# all — no network *and* no cached read ever written. Minute is implicitly 0.
|
||||
@ -26,129 +23,16 @@ DISMISS_WINDOW_MINUTES: int = 30
|
||||
# the one direction the whole design refuses to allow.
|
||||
FALLBACK_ALARM_HOUR: int = 8
|
||||
|
||||
# Alarm escalation phase boundaries (minutes from alarm start).
|
||||
#
|
||||
# Compressed to fit INSIDE the 5-minute failsafe window. They used to be 5 and
|
||||
# 15, which -- once the failsafe was added at 300 s -- meant the alarm was
|
||||
# silenced before it ever left the softest rung, and the medium and loud
|
||||
# phases became unreachable code. The alarm has to reach its most annoying
|
||||
# setting well before it goes quiet, or waiting out five minutes in bed is a
|
||||
# winning strategy.
|
||||
PHASE_SOFT_END: int = 1
|
||||
PHASE_MEDIUM_END: int = 2
|
||||
# After PHASE_MEDIUM_END: alternating loud tones until the failsafe silences.
|
||||
|
||||
# Beep intervals per phase (seconds)
|
||||
SOFT_BEEP_INTERVAL: float = 5.0
|
||||
MEDIUM_BEEP_INTERVAL: float = 3.0
|
||||
LOUD_TOGGLE_INTERVAL: float = 2.0
|
||||
|
||||
# Dismiss challenge: length of the random code
|
||||
DISMISS_CODE_LENGTH: int = 10
|
||||
# Number of correct code entries required to dismiss the alarm.
|
||||
# Requiring more than one round forces the user to stay awake long enough
|
||||
# to actually read and type multiple independent codes.
|
||||
DISMISS_ROUNDS_REQUIRED: int = 4
|
||||
# Seconds the code is visible before being hidden in a flash challenge.
|
||||
DISMISS_FLASH_SECONDS: int = 4
|
||||
# How often the dismiss code refreshes (seconds)
|
||||
DISMISS_CODE_REFRESH_SECONDS: int = 30
|
||||
|
||||
# State file for wake alarm (HMAC-signed)
|
||||
WAKE_STATE_FILE: Path = Path(__file__).resolve().parent / "wake_state.json"
|
||||
|
||||
# rtcwake binary path
|
||||
RTCWAKE_BIN: str = "/usr/sbin/rtcwake"
|
||||
|
||||
# Preferred alarm audio output (machine-specific, empirically verified).
|
||||
# At wake time the Bluetooth speaker is disconnected and PipeWire often has only
|
||||
# the auto_null sink, so the alarm activates every card and plays on all sinks.
|
||||
# This is the G27Q monitor's built-in HDMI speaker — the one always-present
|
||||
# output on this machine — used as the preferred default sink when present.
|
||||
ALARM_AUDIO_SINK: str = "alsa_output.pci-0000_01_00.1.hdmi-stereo"
|
||||
|
||||
# Path to the workout log written by the companion screen_locker package.
|
||||
# Dict keyed by YYYY-MM-DD date strings; presence of today's key means the
|
||||
# workout was already completed and the alarm should not fire.
|
||||
WORKOUT_LOG_FILE: Path = (
|
||||
Path.home() / "screen-locker" / "screen_locker" / "workout_log.json"
|
||||
)
|
||||
|
||||
# --- Phone alarm sync (crdt_sync over GitHub) -----------------------------
|
||||
# The phone app pushes its alarm to a private repo as a crdt_sync log; the PC
|
||||
# reads it back. shutdown-wrapper.sh reads the SAME file with jq for the
|
||||
# hibernate fallback — keep the two readers in step if any of this changes.
|
||||
|
||||
# GitHub token config, key "token" (PAT with "repo" scope: the repo is private).
|
||||
# The alarm's escape-hatch history. Lives beside the sync config rather
|
||||
# than in the package dir, so a reinstall cannot silently reset the budget.
|
||||
ESCAPE_HISTORY_FILE: Path = (
|
||||
Path.home() / ".config" / "wake_alarm" / "escape_history.json"
|
||||
)
|
||||
# How long the alarm must have been up before the hatch is offered, so it
|
||||
# is never the easy first move.
|
||||
#
|
||||
# Deliberately NOT equal to FAILSAFE_SILENCE_SECONDS. Both used to sit at 300,
|
||||
# which meant the sound stopped and the escape hatch appeared in the same
|
||||
# instant -- making the hatch far more attractive than it was designed to be,
|
||||
# exactly when the alarm had just stopped being annoying.
|
||||
ESCAPE_OFFER_AFTER_SECONDS: int = 900
|
||||
|
||||
# Pause between powering the smart plug and the first beep. A powered
|
||||
# speaker's soft-start / mute relay typically delays output 1-5 s after mains
|
||||
# is applied, so firing the plug and the first tone together loses the opening
|
||||
# of the alarm. Costs nothing when no plug is configured.
|
||||
PLUG_PREROLL_SECONDS: float = 4.0
|
||||
|
||||
# The fire-time session record. Beside the sync config rather than in the
|
||||
# package dir, so a reinstall cannot erase evidence that an alarm was cut
|
||||
# short. Distinct from WAKE_STATE_FILE, which records only *dismissal* and is
|
||||
# also read cross-repo by screen_locker/_wake_state.py.
|
||||
ALARM_SESSION_FILE: Path = Path.home() / ".config" / "wake_alarm" / "alarm_session.json"
|
||||
|
||||
# How long after fire time an unfinished session still re-fires the alarm.
|
||||
# Bounded because nothing clears the record when the alarm process dies before
|
||||
# dismissal: unbounded, a crash at startup would restart the alarm every minute
|
||||
# forever. Long enough to survive a power-off plus a boot, short enough that a
|
||||
# session can never leak into the following night.
|
||||
REFIRE_MAX_AGE_SECONDS: float = 5400.0
|
||||
|
||||
# How far a session record may be stamped in the *future* and still count as
|
||||
# ringing. A backward clock jump (NTP correcting after a resume, RTC skew at
|
||||
# boot) is routine on a machine that hibernates nightly, and discarding such a
|
||||
# record would fail open on exactly the power-off escape the record closes.
|
||||
# Bounded so a wildly wrong RTC still cannot create an endless re-fire.
|
||||
CLOCK_SKEW_TOLERANCE_SECONDS: float = 900.0
|
||||
|
||||
# --- Power-button inhibitor (item 2) --------------------------------------
|
||||
# What logind is asked to block while the alarm rings. handle-power-key stops
|
||||
# the short press; shutdown and idle stop a scripted poweroff and the idle
|
||||
# timer from doing the same job by another route.
|
||||
INHIBIT_WHAT: str = "handle-power-key:shutdown:idle"
|
||||
|
||||
# The --who string. Also the pkill pattern used to clear a stale blocker from a
|
||||
# crashed alarm, so it must stay distinctive.
|
||||
INHIBIT_WHO: str = "wake-alarm"
|
||||
INHIBIT_WHY: str = "Wake alarm is ringing and must be dismissed, not switched off"
|
||||
|
||||
# The inhibitor child self-terminates after this long even if nobody releases
|
||||
# it. An orphaned block-mode inhibitor holding handle-power-key would leave the
|
||||
# power button dead until reboot -- a worse failure than the one being fixed --
|
||||
# so it is never `sleep infinity`. Comfortably longer than any real alarm.
|
||||
INHIBIT_MAX_SECONDS: float = 7200.0
|
||||
|
||||
# How long to wait for the inhibitor child to prove it survived. Popen
|
||||
# succeeding only means the binary launched; logind can still refuse (polkit
|
||||
# denies a non-interactive session, seen in a vmbox run: "Failed to inhibit:
|
||||
# Access denied", exit 1). Still alive after this means the inhibit was
|
||||
# accepted. Short enough not to delay the alarm perceptibly.
|
||||
INHIBIT_CONFIRM_SECONDS: float = 0.5
|
||||
|
||||
# When the failsafe silences the alarm. Audio ONLY: the lock, the typed
|
||||
# challenge and the workout gate all persist past this point, so waiting it
|
||||
# out in bed is not a way to escape the alarm. It exists so a ring nobody is
|
||||
# present to dismiss does not sound indefinitely.
|
||||
FAILSAFE_SILENCE_SECONDS: float = 300.0
|
||||
|
||||
SYNC_CONFIG_FILE: Path = Path.home() / ".config" / "wake_alarm" / "sync.json"
|
||||
|
||||
@ -165,12 +49,6 @@ SYNC_TOKEN_FALLBACK_FILES: tuple[Path, ...] = (
|
||||
# Where a successful read is cached, so a network blip can't cost a wake-up.
|
||||
ALARM_CACHE_FILE: Path = Path.home() / ".config" / "wake_alarm" / "alarm_cache.json"
|
||||
|
||||
# Delays between card-profile reactivation passes while the alarm rings.
|
||||
# The G27Q's HDMI sink takes ~15 s to enumerate after the monitor wakes, and
|
||||
# until its card profile is switched on the sink does not exist at all -- so
|
||||
# re-enumerating sinks alone can never find it. Decaying schedule: dense early
|
||||
# (when outputs are still arriving), sparse later, stopping at ~2 minutes.
|
||||
REACTIVATE_ATTEMPT_DELAYS: tuple[float, ...] = (3.0, 5.0, 7.0, 10.0, 15.0, 20.0, 30.0)
|
||||
|
||||
# Structured logs live under XDG state, not .config: they are churning runtime
|
||||
# data, not configuration. The JSONL file is additive -- the journal keeps
|
||||
@ -237,14 +115,3 @@ TRIGGER_CATCHUP_MINUTES: int = 60
|
||||
# wake-alarm.service directly: the orchestrator runs the alarm first and the
|
||||
# workout lock after, so the two never fight for the fullscreen.
|
||||
TRIGGER_TARGET_UNIT: str = "morning-routine.service"
|
||||
|
||||
# TP-Link Tapo P110 smart-plug config file (JSON).
|
||||
# Create with mode 0600 and these keys: host, email, password.
|
||||
# Example contents: a JSON object mapping host -> "192.168.x.x", email ->
|
||||
# "tapo@example.com" and password -> "your-password".
|
||||
# Missing/invalid file => smart-plug control is skipped silently.
|
||||
TAPO_CONFIG_FILE: Path = Path.home() / ".config" / "wake_alarm" / "tapo.json"
|
||||
|
||||
# Timeout (seconds) for a single Tapo plug operation. Keep short so a
|
||||
# missing/unreachable plug never delays the alarm by more than this.
|
||||
TAPO_TIMEOUT_SECONDS: float = 5.0
|
||||
|
||||
@ -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())
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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"))
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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"})
|
||||
@ -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
|
||||
@ -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,
|
||||
)
|
||||
@ -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!")
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -1,4 +1,13 @@
|
||||
"""HMAC-signed state management for the weekend wake alarm."""
|
||||
"""HMAC-signed record of whether the trigger already fired today.
|
||||
|
||||
The PC no longer wakes anyone, so this is not a record of a *dismissal* any
|
||||
more — it is the trigger's once-per-day latch. It has to exist: the tick runs
|
||||
every minute and the catch-up window is ``TRIGGER_CATCHUP_MINUTES`` wide, so
|
||||
without a latch the morning routine would be restarted on every tick for the
|
||||
rest of that window, relaunching the workout lock each time the user finished
|
||||
it. The alarm's own dismissal used to provide that latch; deleting the alarm
|
||||
without replacing it would have been a silent regression.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -11,7 +20,7 @@ from gatelock.log_integrity import (
|
||||
verify_entry_hmac,
|
||||
)
|
||||
|
||||
from wake_alarm._constants import WAKE_STATE_FILE, WORKOUT_LOG_FILE
|
||||
from wake_alarm._constants import WAKE_STATE_FILE
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@ -21,24 +30,18 @@ def _today_str() -> str:
|
||||
return datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def save_wake_state(
|
||||
*,
|
||||
dismissed_at: str | None,
|
||||
skip_workout: bool,
|
||||
) -> bool:
|
||||
"""Write today's wake state with HMAC signature.
|
||||
def save_trigger_fired(*, fired_at: str) -> bool:
|
||||
"""Record that the morning routine was started today.
|
||||
|
||||
Args:
|
||||
dismissed_at: ISO time when alarm was dismissed, or None.
|
||||
skip_workout: Whether the user earned a workout skip.
|
||||
fired_at: ISO timestamp of the tick that started the routine.
|
||||
|
||||
Returns:
|
||||
True if saved successfully, False otherwise.
|
||||
"""
|
||||
entry: dict[str, object] = {
|
||||
"date": _today_str(),
|
||||
"dismissed_at": dismissed_at,
|
||||
"skip_workout": skip_workout,
|
||||
"fired_at": fired_at,
|
||||
}
|
||||
signature = compute_entry_hmac(entry)
|
||||
if signature is not None:
|
||||
@ -53,16 +56,12 @@ def save_wake_state(
|
||||
_logger.warning("Failed to save wake state: %s", exc)
|
||||
return False
|
||||
|
||||
_logger.info(
|
||||
"Saved wake state: dismissed=%s skip=%s",
|
||||
dismissed_at,
|
||||
skip_workout,
|
||||
)
|
||||
_logger.info("Recorded trigger fired at %s", fired_at)
|
||||
return True
|
||||
|
||||
|
||||
def load_wake_state() -> dict[str, object] | None:
|
||||
"""Load and verify today's wake state.
|
||||
"""Load and verify today's trigger record.
|
||||
|
||||
Returns the state dict if it exists, is valid (HMAC OK), and is
|
||||
for today. Returns None otherwise.
|
||||
@ -90,40 +89,9 @@ def load_wake_state() -> dict[str, object] | None:
|
||||
return state
|
||||
|
||||
|
||||
def has_workout_skip_today() -> bool:
|
||||
"""Check if the user earned a workout skip for today."""
|
||||
def was_trigger_fired_today() -> bool:
|
||||
"""Check whether the morning routine was already started today."""
|
||||
state = load_wake_state()
|
||||
if state is None:
|
||||
return False
|
||||
return bool(state.get("skip_workout"))
|
||||
|
||||
|
||||
def was_alarm_dismissed_today() -> bool:
|
||||
"""Check if the alarm was already dismissed today."""
|
||||
state = load_wake_state()
|
||||
if state is None:
|
||||
return False
|
||||
return state.get("dismissed_at") is not None
|
||||
|
||||
|
||||
def was_workout_logged_today() -> bool:
|
||||
"""Check if the workout was already logged today via the screen locker.
|
||||
|
||||
Reads the companion screen_locker workout_log.json. The file is a
|
||||
dict keyed by YYYY-MM-DD date strings; presence of today's key means
|
||||
the workout was completed and the alarm is no longer needed.
|
||||
|
||||
Returns:
|
||||
True if today's workout entry exists, False on any error or absence.
|
||||
"""
|
||||
if not WORKOUT_LOG_FILE.exists():
|
||||
return False
|
||||
try:
|
||||
with WORKOUT_LOG_FILE.open() as f:
|
||||
log = json.load(f)
|
||||
except OSError, json.JSONDecodeError:
|
||||
_logger.warning("Cannot read workout log file %s", WORKOUT_LOG_FILE)
|
||||
return False
|
||||
if not isinstance(log, dict):
|
||||
return False
|
||||
return _today_str() in log
|
||||
return state.get("fired_at") is not None
|
||||
|
||||
@ -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
|
||||
@ -26,7 +26,6 @@ import logging
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from wake_alarm._alarm_session import should_refire
|
||||
from wake_alarm._constants import (
|
||||
ALARM_DAYS,
|
||||
FALLBACK_ALARM_HOUR,
|
||||
@ -35,8 +34,8 @@ from wake_alarm._constants import (
|
||||
)
|
||||
from wake_alarm._logging_setup import configure_logging
|
||||
from wake_alarm._state import (
|
||||
was_alarm_dismissed_today,
|
||||
was_workout_logged_today,
|
||||
save_trigger_fired,
|
||||
was_trigger_fired_today,
|
||||
)
|
||||
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.
|
||||
|
||||
Checks only the gates that are independent of the clock: the day gate and
|
||||
the two "already handled today" gates shared with ``_alarm.py``.
|
||||
the once-per-day latch.
|
||||
|
||||
Args:
|
||||
now: Current local time.
|
||||
@ -93,10 +92,8 @@ def _skip_reason(now: datetime) -> str | None:
|
||||
"""
|
||||
if now.weekday() not in ALARM_DAYS:
|
||||
return "not an alarm day"
|
||||
if was_alarm_dismissed_today():
|
||||
return "alarm already dismissed today"
|
||||
if was_workout_logged_today():
|
||||
return "workout already logged today"
|
||||
if was_trigger_fired_today():
|
||||
return "already fired today"
|
||||
return None
|
||||
|
||||
|
||||
@ -140,15 +137,6 @@ def _decide(now: datetime, alarm: Alarm) -> tuple[bool, str]:
|
||||
if skip is not None:
|
||||
return False, skip
|
||||
|
||||
# An alarm that was cut short -- almost always a power-button press or a
|
||||
# hard power-off -- left a "ringing" session record that nothing cleared.
|
||||
# It re-fires regardless of the catch-up window, because the whole point is
|
||||
# that the machine may have been off for the rest of that window. The day /
|
||||
# dismissed / workout gates above still apply, and the record itself is
|
||||
# age-bounded, so this cannot loop forever on a crashed alarm.
|
||||
if should_refire(now=now):
|
||||
return True, "an earlier alarm was cut short and never dismissed"
|
||||
|
||||
fire_at, window_end = _fire_window(alarm, now)
|
||||
if now < fire_at:
|
||||
return False, f"alarm {alarm} is still ahead"
|
||||
@ -235,7 +223,12 @@ def main() -> None:
|
||||
)
|
||||
|
||||
_logger.warning("Firing %s: %s", TRIGGER_TARGET_UNIT, reason)
|
||||
_start_target_unit()
|
||||
if _start_target_unit():
|
||||
# Latch immediately. The tick runs every minute for the rest of the
|
||||
# catch-up window, and the unit is Type=simple: a start while it is
|
||||
# running is a no-op, but the moment the workout lock finishes the
|
||||
# next tick would relaunch it on top of the user.
|
||||
save_trigger_fired(fired_at=now.isoformat())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -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]
|
||||
@ -1,40 +1,26 @@
|
||||
"""Shared fixtures keeping the wake_alarm suite off the real machine.
|
||||
|
||||
Since gatelock v0.2.0 the lock builds one real ``tk.Toplevel`` per live
|
||||
output. Two consequences for tests, both of which bite silently:
|
||||
Two hazards are left now that the PC no longer owns a lock screen, audio or
|
||||
an inhibitor:
|
||||
|
||||
* ``tk.Toplevel`` over a ``MagicMock`` master sends real tkinter into infinite
|
||||
mock recursion -- the run **hangs** rather than failing, which is far harder
|
||||
to diagnose than an error.
|
||||
* An unpatched lock setup shells out to ``setxkbmap`` and really disables VT
|
||||
switching on the machine running the tests, and reaches the real
|
||||
``$XDG_RUNTIME_DIR``, where it could stand a *production* locker down.
|
||||
|
||||
Sink reactivation adds a fourth: ``WakeAlarm.__init__`` starts a background
|
||||
thread that really shells out to ``pactl set-card-profile`` and would reshape
|
||||
the *host's* audio routing mid-test.
|
||||
|
||||
The power-key inhibitor and the session record add a fifth and a sixth: an
|
||||
unstubbed run really spawns ``systemd-inhibit`` (blocking the machine's power
|
||||
button for as long as the test process lives) and really writes the
|
||||
*production* ``alarm_session.json``, which the next real trigger tick would
|
||||
then act on. All three are stubbed here and re-patched explicitly by the tests
|
||||
that assert the wiring.
|
||||
|
||||
The escape hatch adds a third: ``WakeAlarm.__init__`` builds its tracker over
|
||||
the real ``ESCAPE_HISTORY_FILE``, so an unpatched run both reads a *production*
|
||||
budget -- making "the button appears" tests fail on any day the real budget is
|
||||
spent -- and could write to it.
|
||||
|
||||
So this is autouse and deliberately broad.
|
||||
* ``_state`` signs its records with gatelock's HMAC key, and the real key is
|
||||
root-owned but *world-readable*. An unpatched run would sign test entries
|
||||
with the **production** key on this machine and with none at all anywhere
|
||||
else, so the suite would pass here and behave differently in CI.
|
||||
* ``WAKE_STATE_FILE`` is a real path inside the repo, and ``_trigger`` now
|
||||
*writes* it. The trigger tests never redirected it, because until the
|
||||
once-per-day latch moved into the trigger nothing on that path wrote
|
||||
anything. Redirecting it here rather than per-file is deliberate: a
|
||||
per-file redirect is exactly the kind that gets forgotten when a new writer
|
||||
appears, and the failure is silent -- a green suite that quietly stamped
|
||||
the live state file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
from gatelock import Output, OutputRect
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -42,83 +28,13 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FAKE_OUTPUTS = (
|
||||
Output(
|
||||
"VIRTUAL-1",
|
||||
connected=True,
|
||||
rect=OutputRect(0, 0, 1920, 1080),
|
||||
primary=True,
|
||||
),
|
||||
)
|
||||
"""One live output, so the Tk-screen fallback (which would call int() on a
|
||||
MagicMock) is never reached."""
|
||||
|
||||
TWO_OUTPUTS = (
|
||||
Output("DP-0", connected=True, rect=OutputRect(0, 0, 3840, 2160), primary=True),
|
||||
Output("HDMI-0", connected=True, rect=OutputRect(3840, 0, 2560, 1440)),
|
||||
)
|
||||
"""The real desk. Opt into it with the ``dual_output`` fixture for anything
|
||||
asserting that work fans out: against ``FAKE_OUTPUTS`` a loop that only ever
|
||||
touched the first surface would pass every assertion."""
|
||||
|
||||
|
||||
def make_toplevel(_parent: object = None, **_kwargs: object) -> MagicMock:
|
||||
"""A Toplevel stand-in that remembers the geometry it was given."""
|
||||
window = MagicMock()
|
||||
state = {"x": 0, "y": 0, "w": 0, "h": 0, "mapped": False}
|
||||
|
||||
def geometry(spec: str) -> None:
|
||||
size, x, y = spec.split("+")
|
||||
width, height = size.split("x")
|
||||
state.update(x=int(x), y=int(y), w=int(width), h=int(height))
|
||||
|
||||
window.geometry.side_effect = geometry
|
||||
window.deiconify.side_effect = lambda: state.update(mapped=True)
|
||||
window.withdraw.side_effect = lambda: state.update(mapped=False)
|
||||
window.winfo_ismapped.side_effect = lambda: state["mapped"]
|
||||
window.winfo_rootx.side_effect = lambda: state["x"]
|
||||
window.winfo_rooty.side_effect = lambda: state["y"]
|
||||
window.winfo_width.side_effect = lambda: state["w"]
|
||||
window.winfo_height.side_effect = lambda: state["h"]
|
||||
return window
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hermetic(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[None]:
|
||||
"""Block every path from the lock to the real display or machine."""
|
||||
monkeypatch.setenv("GATELOCK_RUNTIME_DIR", str(tmp_path / "gatelock-runtime"))
|
||||
def _hermetic(tmp_path: Path) -> Generator[None]:
|
||||
"""Keep signing keys and state writes inside ``tmp_path``."""
|
||||
key_file = tmp_path / "hmac.key"
|
||||
key_file.write_bytes(b"0" * 32)
|
||||
with (
|
||||
# The real key is root-owned but world-readable, so an unpatched run
|
||||
# signs test entries with the *production* key on this machine and
|
||||
# with none at all anywhere else. Pin it so the suite is portable.
|
||||
patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key_file),
|
||||
patch(
|
||||
"wake_alarm._escape_flow.ESCAPE_HISTORY_FILE",
|
||||
tmp_path / "escape_history.json",
|
||||
),
|
||||
patch("wake_alarm._alarm.start_alarm_reactivation"),
|
||||
patch("wake_alarm._alarm.take_power_key_inhibitor", return_value=None),
|
||||
patch("wake_alarm._alarm.release_power_key_inhibitor"),
|
||||
patch("wake_alarm._alarm.start_session"),
|
||||
patch("wake_alarm._alarm.finish_session"),
|
||||
patch("gatelock._surfaces.tk.Toplevel", side_effect=make_toplevel),
|
||||
patch("gatelock._outputs.RandrBackend.create", return_value=None),
|
||||
patch("gatelock._outputs.scan_xrandr", return_value=FAKE_OUTPUTS),
|
||||
patch("gatelock._detect._RandrEventSource.start", return_value=False),
|
||||
patch("gatelock._vt.shutil.which", return_value="/usr/bin/setxkbmap"),
|
||||
patch("gatelock._vt.subprocess.run"),
|
||||
patch("wake_alarm._state.WAKE_STATE_FILE", tmp_path / "wake_state.json"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dual_output(_hermetic: None) -> Generator[None]:
|
||||
"""Re-scan as a two-monitor desk, layered over the single-output default.
|
||||
|
||||
Depends on ``_hermetic`` so pytest is obliged to apply it second; the
|
||||
``scan_xrandr`` patch here has to be the one that wins.
|
||||
"""
|
||||
with patch("gatelock._outputs.scan_xrandr", return_value=TWO_OUTPUTS):
|
||||
yield
|
||||
|
||||
@ -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)
|
||||
@ -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()
|
||||
@ -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"}
|
||||
@ -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()
|
||||
@ -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,
|
||||
)
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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")
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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")
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -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)
|
||||
@ -11,7 +11,7 @@ import pytest
|
||||
from wake_alarm._state import (
|
||||
_today_str,
|
||||
load_wake_state,
|
||||
save_wake_state,
|
||||
save_trigger_fired,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -46,7 +46,7 @@ class TestTodayStr:
|
||||
|
||||
|
||||
class TestSaveWakeState:
|
||||
"""Tests for save_wake_state."""
|
||||
"""Tests for save_trigger_fired."""
|
||||
|
||||
def test_saves_with_hmac(self, wake_state_file: Path) -> None:
|
||||
"""Save state with HMAC signature when key is available."""
|
||||
@ -54,15 +54,11 @@ class TestSaveWakeState:
|
||||
"wake_alarm._state.compute_entry_hmac",
|
||||
return_value="fakesig",
|
||||
):
|
||||
result = save_wake_state(
|
||||
dismissed_at="2026-04-12T07:04:00+00:00",
|
||||
skip_workout=True,
|
||||
)
|
||||
result = save_trigger_fired(fired_at="2026-04-12T07:04:00+00:00")
|
||||
|
||||
assert result is True
|
||||
data = json.loads(wake_state_file.read_text())
|
||||
assert data["skip_workout"] is True
|
||||
assert data["dismissed_at"] == "2026-04-12T07:04:00+00:00"
|
||||
assert data["fired_at"] == "2026-04-12T07:04:00+00:00"
|
||||
assert data["hmac"] == "fakesig"
|
||||
assert data["date"] == _today_str()
|
||||
|
||||
@ -72,14 +68,10 @@ class TestSaveWakeState:
|
||||
"wake_alarm._state.compute_entry_hmac",
|
||||
return_value=None,
|
||||
):
|
||||
result = save_wake_state(
|
||||
dismissed_at=None,
|
||||
skip_workout=False,
|
||||
)
|
||||
result = save_trigger_fired(fired_at="07:04")
|
||||
|
||||
assert result is True
|
||||
data = json.loads(wake_state_file.read_text())
|
||||
assert data["skip_workout"] is False
|
||||
assert "hmac" not in data
|
||||
|
||||
def test_returns_false_on_write_error(self, wake_state_file: Path) -> None:
|
||||
@ -94,7 +86,7 @@ class TestSaveWakeState:
|
||||
wake_state_file / "nonexistent_dir" / "file.json",
|
||||
),
|
||||
):
|
||||
result = save_wake_state(dismissed_at=None, skip_workout=False)
|
||||
result = save_trigger_fired(fired_at="07:04")
|
||||
|
||||
assert result is False
|
||||
|
||||
@ -111,7 +103,7 @@ class TestLoadWakeState:
|
||||
wake_state_file: Path,
|
||||
) -> None:
|
||||
"""Return None when state is from a different day."""
|
||||
state = {"date": "1999-01-01", "skip_workout": True, "hmac": "x"}
|
||||
state = {"date": "1999-01-01", "hmac": "x"}
|
||||
wake_state_file.write_text(json.dumps(state))
|
||||
assert load_wake_state() is None
|
||||
|
||||
@ -138,8 +130,7 @@ class TestLoadWakeState:
|
||||
"""Return None when HMAC verification fails."""
|
||||
state = {
|
||||
"date": _today_str(),
|
||||
"skip_workout": True,
|
||||
"dismissed_at": "07:00",
|
||||
"fired_at": "07:00",
|
||||
"hmac": "badsig",
|
||||
}
|
||||
wake_state_file.write_text(json.dumps(state))
|
||||
@ -156,8 +147,7 @@ class TestLoadWakeState:
|
||||
"""Return state dict when file is valid and for today."""
|
||||
state = {
|
||||
"date": _today_str(),
|
||||
"skip_workout": True,
|
||||
"dismissed_at": "07:04",
|
||||
"fired_at": "07:04",
|
||||
"hmac": "validsig",
|
||||
}
|
||||
wake_state_file.write_text(json.dumps(state))
|
||||
@ -168,4 +158,3 @@ class TestLoadWakeState:
|
||||
result = load_wake_state()
|
||||
|
||||
assert result is not None
|
||||
assert result["skip_workout"] is True
|
||||
|
||||
@ -14,9 +14,7 @@ import pytest
|
||||
|
||||
from wake_alarm._state import (
|
||||
_today_str,
|
||||
has_workout_skip_today,
|
||||
was_alarm_dismissed_today,
|
||||
was_workout_logged_today,
|
||||
was_trigger_fired_today,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -39,22 +37,21 @@ def _patch_wake_state_file(wake_state_file: Path) -> None:
|
||||
yield
|
||||
|
||||
|
||||
class TestHasWorkoutSkipToday:
|
||||
"""Tests for has_workout_skip_today."""
|
||||
class TestWasTriggerFiredToday:
|
||||
"""Tests for the trigger's once-per-day latch."""
|
||||
|
||||
def test_returns_false_when_no_state(self) -> None:
|
||||
"""Return False when no state file exists."""
|
||||
assert has_workout_skip_today() is False
|
||||
assert was_trigger_fired_today() is False
|
||||
|
||||
def test_returns_true_when_skip_granted(
|
||||
def test_returns_true_once_fired(
|
||||
self,
|
||||
wake_state_file: Path,
|
||||
) -> None:
|
||||
"""Return True when today's state has skip_workout=True."""
|
||||
"""Return True when the routine already started today."""
|
||||
state = {
|
||||
"date": _today_str(),
|
||||
"skip_workout": True,
|
||||
"dismissed_at": "07:04",
|
||||
"fired_at": "07:04",
|
||||
"hmac": "sig",
|
||||
}
|
||||
wake_state_file.write_text(json.dumps(state))
|
||||
@ -62,17 +59,16 @@ class TestHasWorkoutSkipToday:
|
||||
"wake_alarm._state.verify_entry_hmac",
|
||||
return_value=True,
|
||||
):
|
||||
assert has_workout_skip_today() is True
|
||||
assert was_trigger_fired_today() is True
|
||||
|
||||
def test_returns_false_when_skip_not_granted(
|
||||
def test_returns_false_before_firing(
|
||||
self,
|
||||
wake_state_file: Path,
|
||||
) -> None:
|
||||
"""Return False when today's state has skip_workout=False."""
|
||||
"""Return False when it has not started yet."""
|
||||
state = {
|
||||
"date": _today_str(),
|
||||
"skip_workout": False,
|
||||
"dismissed_at": None,
|
||||
"fired_at": None,
|
||||
"hmac": "sig",
|
||||
}
|
||||
wake_state_file.write_text(json.dumps(state))
|
||||
@ -80,100 +76,4 @@ class TestHasWorkoutSkipToday:
|
||||
"wake_alarm._state.verify_entry_hmac",
|
||||
return_value=True,
|
||||
):
|
||||
assert has_workout_skip_today() is False
|
||||
|
||||
|
||||
class TestWasAlarmDismissedToday:
|
||||
"""Tests for was_alarm_dismissed_today."""
|
||||
|
||||
def test_returns_false_when_no_state(self) -> None:
|
||||
"""Return False when no state file exists."""
|
||||
assert was_alarm_dismissed_today() is False
|
||||
|
||||
def test_returns_true_when_dismissed(
|
||||
self,
|
||||
wake_state_file: Path,
|
||||
) -> None:
|
||||
"""Return True when alarm was dismissed today."""
|
||||
state = {
|
||||
"date": _today_str(),
|
||||
"dismissed_at": "07:04",
|
||||
"skip_workout": True,
|
||||
"hmac": "sig",
|
||||
}
|
||||
wake_state_file.write_text(json.dumps(state))
|
||||
with patch(
|
||||
"wake_alarm._state.verify_entry_hmac",
|
||||
return_value=True,
|
||||
):
|
||||
assert was_alarm_dismissed_today() is True
|
||||
|
||||
def test_returns_false_when_not_dismissed(
|
||||
self,
|
||||
wake_state_file: Path,
|
||||
) -> None:
|
||||
"""Return False when alarm was not dismissed."""
|
||||
state = {
|
||||
"date": _today_str(),
|
||||
"dismissed_at": None,
|
||||
"skip_workout": False,
|
||||
"hmac": "sig",
|
||||
}
|
||||
wake_state_file.write_text(json.dumps(state))
|
||||
with patch(
|
||||
"wake_alarm._state.verify_entry_hmac",
|
||||
return_value=True,
|
||||
):
|
||||
assert was_alarm_dismissed_today() is False
|
||||
|
||||
|
||||
class TestWasWorkoutLoggedToday:
|
||||
"""Tests for was_workout_logged_today."""
|
||||
|
||||
def test_returns_false_when_file_missing(self, tmp_path: Path) -> None:
|
||||
"""Return False when the workout log file does not exist."""
|
||||
with patch(
|
||||
"wake_alarm._state.WORKOUT_LOG_FILE",
|
||||
tmp_path / "workout_log.json",
|
||||
):
|
||||
assert was_workout_logged_today() is False
|
||||
|
||||
def test_returns_false_when_file_is_invalid_json(self, tmp_path: Path) -> None:
|
||||
"""Return False when the workout log contains invalid JSON."""
|
||||
log_file = tmp_path / "workout_log.json"
|
||||
log_file.write_text("not json {{{")
|
||||
with patch(
|
||||
"wake_alarm._state.WORKOUT_LOG_FILE",
|
||||
log_file,
|
||||
):
|
||||
assert was_workout_logged_today() is False
|
||||
|
||||
def test_returns_false_when_file_is_not_a_dict(self, tmp_path: Path) -> None:
|
||||
"""Return False when the workout log is not a JSON object."""
|
||||
log_file = tmp_path / "workout_log.json"
|
||||
log_file.write_text(json.dumps([1, 2, 3]))
|
||||
with patch(
|
||||
"wake_alarm._state.WORKOUT_LOG_FILE",
|
||||
log_file,
|
||||
):
|
||||
assert was_workout_logged_today() is False
|
||||
|
||||
def test_returns_false_when_today_absent(self, tmp_path: Path) -> None:
|
||||
"""Return False when the workout log has no entry for today."""
|
||||
log_file = tmp_path / "workout_log.json"
|
||||
log_file.write_text(json.dumps({"1999-01-01": {"type": "old"}}))
|
||||
with patch(
|
||||
"wake_alarm._state.WORKOUT_LOG_FILE",
|
||||
log_file,
|
||||
):
|
||||
assert was_workout_logged_today() is False
|
||||
|
||||
def test_returns_true_when_today_present(self, tmp_path: Path) -> None:
|
||||
"""Return True when today's date key exists in the workout log."""
|
||||
log_file = tmp_path / "workout_log.json"
|
||||
log_file.write_text(json.dumps({_today_str(): {"type": "phone_verified"}}))
|
||||
with patch(
|
||||
"wake_alarm._state.WORKOUT_LOG_FILE",
|
||||
log_file,
|
||||
):
|
||||
assert was_workout_logged_today() is True
|
||||
assert was_trigger_fired_today() is False
|
||||
|
||||
@ -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()
|
||||
@ -3,15 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from wake_alarm import _alarm_session
|
||||
from wake_alarm._sync import Alarm
|
||||
from wake_alarm._trigger import (
|
||||
_decide,
|
||||
@ -35,14 +30,12 @@ def _at(hour: int, minute: int) -> datetime:
|
||||
def _ungated() -> None:
|
||||
"""Open every gate unless a test closes one.
|
||||
|
||||
``should_refire`` is stubbed here too: unstubbed it reads the *real*
|
||||
The once-per-day latch is stubbed: unstubbed it reads the *real*
|
||||
session record on the host, so a test run started while an alarm was
|
||||
actually ringing would silently invert half these assertions.
|
||||
"""
|
||||
with (
|
||||
patch("wake_alarm._trigger.was_alarm_dismissed_today", return_value=False),
|
||||
patch("wake_alarm._trigger.was_workout_logged_today", return_value=False),
|
||||
patch("wake_alarm._trigger.should_refire", return_value=False),
|
||||
patch("wake_alarm._trigger.was_trigger_fired_today", return_value=False),
|
||||
):
|
||||
yield
|
||||
|
||||
@ -102,18 +95,13 @@ class TestSkipReason:
|
||||
with patch("wake_alarm._trigger.ALARM_DAYS", frozenset()):
|
||||
assert _skip_reason(_at(7, 0)) == "not an alarm day"
|
||||
|
||||
def test_already_dismissed(self) -> None:
|
||||
"""A dismissal recorded today blocks a re-fire."""
|
||||
def test_already_fired_today(self) -> None:
|
||||
"""The once-per-day latch blocks every later tick in the window."""
|
||||
with patch(
|
||||
"wake_alarm._trigger.was_alarm_dismissed_today",
|
||||
"wake_alarm._trigger.was_trigger_fired_today",
|
||||
return_value=True,
|
||||
):
|
||||
assert _skip_reason(_at(7, 0)) == "alarm already dismissed today"
|
||||
|
||||
def test_workout_already_logged(self) -> None:
|
||||
"""A logged workout means the alarm is no longer needed."""
|
||||
with patch("wake_alarm._trigger.was_workout_logged_today", return_value=True):
|
||||
assert _skip_reason(_at(7, 0)) == "workout already logged today"
|
||||
assert _skip_reason(_at(7, 0)) == "already fired today"
|
||||
|
||||
|
||||
class TestDecide:
|
||||
@ -150,82 +138,14 @@ class TestDecide:
|
||||
def test_gate_beats_the_clock(self) -> None:
|
||||
"""A closed gate wins even at the exact alarm minute."""
|
||||
with patch(
|
||||
"wake_alarm._trigger.was_alarm_dismissed_today",
|
||||
"wake_alarm._trigger.was_trigger_fired_today",
|
||||
return_value=True,
|
||||
):
|
||||
fire, reason = _decide(_at(6, 45), Alarm(6, 45, "remote"))
|
||||
assert not fire
|
||||
assert reason == "alarm already dismissed today"
|
||||
assert reason == "already fired today"
|
||||
|
||||
def test_alarm_moved_earlier_fires_immediately(self) -> None:
|
||||
"""A phone alarm moved back before now fires on the very next tick."""
|
||||
fire, _ = _decide(_at(6, 50), Alarm(6, 40, "remote"))
|
||||
assert fire
|
||||
|
||||
|
||||
class TestRefireOnAnUnfinishedSession:
|
||||
"""A power-off mid-alarm must not be a way out."""
|
||||
|
||||
def test_refires_long_after_the_window_closed(self) -> None:
|
||||
"""The whole point: the machine may have been off for the window.
|
||||
|
||||
Without this, holding the power button ends the alarm for good --
|
||||
the catch-up window closes while the PC is down and nothing on disk
|
||||
ever said an alarm was ringing.
|
||||
"""
|
||||
with patch("wake_alarm._trigger.should_refire", return_value=True):
|
||||
fire, reason = _decide(_at(20, 0), Alarm(6, 45, "remote"))
|
||||
assert fire
|
||||
assert "cut short" in reason
|
||||
|
||||
def test_dismissed_gate_still_beats_a_refire(self) -> None:
|
||||
"""The ordinary gates are checked first and still win."""
|
||||
with (
|
||||
patch("wake_alarm._trigger.should_refire", return_value=True),
|
||||
patch(
|
||||
"wake_alarm._trigger.was_alarm_dismissed_today",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
fire, reason = _decide(_at(20, 0), Alarm(6, 45, "remote"))
|
||||
assert not fire
|
||||
assert reason == "alarm already dismissed today"
|
||||
|
||||
def test_end_to_end_through_a_real_session_record(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""_decide against a real record, with should_refire NOT patched.
|
||||
|
||||
Every other test here stubs ``should_refire``, so the wiring between
|
||||
the record on disk and the decision was untested -- and a manual run
|
||||
found it broken while the whole suite stayed green. This is the
|
||||
deterministic version of that manual check.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
_alarm_session, "ALARM_SESSION_FILE", tmp_path / "session.json"
|
||||
)
|
||||
now = _at(20, 0)
|
||||
# The record is stamped by the alarm process, which ran at fire time --
|
||||
# not at this tick. Pin its clock to the same fictional morning as the
|
||||
# tick, or the two dates are years apart and the age check is
|
||||
# meaningless.
|
||||
monkeypatch.setattr(_alarm_session, "_now", lambda: _at(19, 30))
|
||||
alarm = Alarm(6, 45, "remote")
|
||||
|
||||
with patch("wake_alarm._trigger.should_refire", _alarm_session.should_refire):
|
||||
assert _decide(now, alarm)[0] is False # window closed, no record
|
||||
|
||||
_alarm_session.start_session("wake-20260719-0645")
|
||||
fire, reason = _decide(now, alarm)
|
||||
assert fire, "a cut-short alarm must ring again"
|
||||
assert "cut short" in reason
|
||||
|
||||
_alarm_session.finish_session()
|
||||
assert _decide(now, alarm)[0] is False # dismissed stays dismissed
|
||||
|
||||
def test_refire_is_asked_about_the_tick_instant(self) -> None:
|
||||
"""The session's age is measured against the tick, not wall-clock now."""
|
||||
with patch("wake_alarm._trigger.should_refire") as refire:
|
||||
refire.return_value = False
|
||||
_decide(_at(20, 0), Alarm(6, 45, "remote"))
|
||||
refire.assert_called_once_with(now=_at(20, 0))
|
||||
|
||||
@ -28,14 +28,12 @@ def _at(hour: int, minute: int) -> datetime:
|
||||
def _ungated() -> None:
|
||||
"""Open every gate unless a test closes one.
|
||||
|
||||
``should_refire`` is stubbed here too: unstubbed it reads the *real*
|
||||
The once-per-day latch is stubbed: unstubbed it reads the *real*
|
||||
session record on the host, so a test run started while an alarm was
|
||||
actually ringing would silently invert half these assertions.
|
||||
"""
|
||||
with (
|
||||
patch("wake_alarm._trigger.was_alarm_dismissed_today", return_value=False),
|
||||
patch("wake_alarm._trigger.was_workout_logged_today", return_value=False),
|
||||
patch("wake_alarm._trigger.should_refire", return_value=False),
|
||||
patch("wake_alarm._trigger.was_trigger_fired_today", return_value=False),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@ -34,14 +34,12 @@ def _at(hour: int, minute: int) -> datetime:
|
||||
def _ungated() -> None:
|
||||
"""Open every gate unless a test closes one.
|
||||
|
||||
``should_refire`` is stubbed here too: unstubbed it reads the *real*
|
||||
The once-per-day latch is stubbed: unstubbed it reads the *real*
|
||||
session record on the host, so a test run started while an alarm was
|
||||
actually ringing would silently invert half these assertions.
|
||||
"""
|
||||
with (
|
||||
patch("wake_alarm._trigger.was_alarm_dismissed_today", return_value=False),
|
||||
patch("wake_alarm._trigger.was_workout_logged_today", return_value=False),
|
||||
patch("wake_alarm._trigger.should_refire", return_value=False),
|
||||
patch("wake_alarm._trigger.was_trigger_fired_today", return_value=False),
|
||||
):
|
||||
yield
|
||||
|
||||
@ -97,6 +95,43 @@ class TestMain:
|
||||
main()
|
||||
return start
|
||||
|
||||
def test_a_successful_start_latches_the_day(self) -> None:
|
||||
"""Firing records the latch, so later ticks in the window skip.
|
||||
|
||||
Without this the routine is restarted on every tick for the rest of
|
||||
``TRIGGER_CATCHUP_MINUTES``: the unit is Type=simple, so a start while
|
||||
it runs is a no-op, but the moment the workout lock finishes the next
|
||||
tick relaunches it on top of the user.
|
||||
"""
|
||||
with (
|
||||
patch("sys.argv", ["_trigger"]),
|
||||
patch("wake_alarm._trigger._now", return_value=_at(6, 45)),
|
||||
patch(
|
||||
"wake_alarm._trigger.get_alarm",
|
||||
return_value=Alarm(6, 45, "remote"),
|
||||
),
|
||||
patch("wake_alarm._trigger._start_target_unit", return_value=True),
|
||||
patch("wake_alarm._trigger.save_trigger_fired") as saved,
|
||||
):
|
||||
main()
|
||||
assert saved.call_count == 1
|
||||
assert saved.call_args.kwargs["fired_at"] == _at(6, 45).isoformat()
|
||||
|
||||
def test_a_failed_start_does_not_latch(self) -> None:
|
||||
"""A start that failed must stay retryable on the next tick."""
|
||||
with (
|
||||
patch("sys.argv", ["_trigger"]),
|
||||
patch("wake_alarm._trigger._now", return_value=_at(6, 45)),
|
||||
patch(
|
||||
"wake_alarm._trigger.get_alarm",
|
||||
return_value=Alarm(6, 45, "remote"),
|
||||
),
|
||||
patch("wake_alarm._trigger._start_target_unit", return_value=False),
|
||||
patch("wake_alarm._trigger.save_trigger_fired") as saved,
|
||||
):
|
||||
main()
|
||||
saved.assert_not_called()
|
||||
|
||||
def test_fires_at_the_alarm_minute(self) -> None:
|
||||
"""On the alarm minute the target unit is started."""
|
||||
start = self._run_main([], _at(6, 45), Alarm(6, 45, "remote"))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user