Phase 0: fix dependency pin mismatch, stale docs, add speaker research

Groundwork for the alarm-hardening work. No behaviour change.

- .pre-commit-config.yaml pinned gatelock-v0.4.0 while pyproject.toml and
  requirements.txt pinned v0.4.1, so pylint type-checked against a different
  gatelock than production runs. All three now agree on v0.4.1.
- AGENTS.md claimed ALARM_DAYS is "Mon, Fri, Sat, Sun"; _constants.py:9 is
  frozenset({0..6}), i.e. every day. Corrected.
- Complete the CLAUDE.md -> AGENTS.md symlink migration that was left
  uncommitted in the working tree.
- DOCS-speaker-recommendation.md: item 7 of the hardening work. Prose only.
  Researched from official manuals/vendor FAQs. The deciding spec turns out
  to be the power switch, not the LED: _alarm.py already cuts plug mains on
  every exit path, so a speaker needs a latching mechanical switch to come
  back without a button press. Anything shipping a remote has a standby
  microcontroller that a mains cycle cannot clear (confirmed by Edifier's own
  support article), which disqualifies it.
- Drop the stale "TODO: Specify your own unique Application ID" template
  comment; applicationId has been set to com.kuhy.wake_alarm_sync all along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
This commit is contained in:
Krzysztof Rudnicki 2026-08-26 22:53:01 +02:00
parent 9379f14779
commit 4286fcb019
5 changed files with 336 additions and 236 deletions

View File

@ -96,7 +96,7 @@ repos:
additional_dependencies:
- pytest
- python-kasa
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
- 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.
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)

234
AGENTS.md Normal file
View File

@ -0,0 +1,234 @@
# CLAUDE.md — wake_alarm
## 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`), ramps fans to 100% via `wake-alarm-fans.sh`, plays audio
through whatever sink comes up after the monitor wakes, and optionally
toggles a TP-Link Tapo P110 smart plug via `python-kasa`.
## Scheduling — a per-minute timer, with hibernate as the fallback
The PC 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.
**Primary path — `wake-alarm-trigger.timer`:**
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.
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.
**Fallback path — hibernate (only when the PC really is powered down):**
`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.
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`).
**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.service` must never auto-start
`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.
## 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.
- 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.
- **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
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**.
- `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.
- **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`.
## Cross-repo coupling — not a bug
`_constants.py`'s `WORKOUT_LOG_FILE` points at
`~/screen-locker/screen_locker/workout_log.json` — a file owned by the
separate, already-standalone `screen-locker` repo
(https://github.com/kuhyx/screen-locker). This is intentional: the alarm
reads whether today's workout was already logged by screen-locker to decide
whether the morning routine should also lock the workout screen. If this
path ever raises `ModuleNotFoundError`-style confusion, the bug is almost
certainly in the **orchestrator** (`morning_routine` in testsAndMisc), not
here — see the next section.
## The morning_routine orchestrator lives elsewhere
`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.
## Production dependency installation — read this before adding any dependency
`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*
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.
`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.
## 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. It used to be
accidentally committed in the monorepo; it is gitignored here and must
stay that way.
## Commands
- Run tests: `python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100`
- 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 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`
Note the dev venv lacks `gatelock`, so anything importing `_state` (including
`_trigger`) must be run with `/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 or
invocation — see "The morning_routine orchestrator lives elsewhere" above.
- Don't give `wake-alarm.service` an `[Install]` section or enable it. It
fires at login time, not alarm time — see the section above.
- Don't make the trigger start `wake-alarm.service`; it must start
`morning-routine.service` so the workout lock still runs.
- Don't raise the log level of the routine degraded-read paths in `_sync` —
the per-minute tick turns any WARNING there into journal spam.
- Don't commit `wake_state.json`.

234
CLAUDE.md
View File

@ -1,234 +0,0 @@
# CLAUDE.md — wake_alarm
## What this does
A weekend wake alarm: on alarm days (`ALARM_DAYS` in `_constants.py` — Mon,
Fri, Sat, Sun), the machine hibernates overnight and wakes itself via
`rtcwake` at the configured alarm time. `wake-alarm.service` then opens a
fullscreen Tk window that must be dismissed (with a typed challenge — see
`_challenges.py`), ramps fans to 100% via `wake-alarm-fans.sh`, plays audio
through whatever sink comes up after the monitor wakes, and optionally
toggles a TP-Link Tapo P110 smart plug via `python-kasa`.
## Scheduling — a per-minute timer, with hibernate as the fallback
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.
**Primary path — `wake-alarm-trigger.timer`:**
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.
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.
**Fallback path — hibernate (only when the PC really is powered down):**
`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.
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`).
**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.service` must never auto-start
`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.
## 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.
- 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.
- **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
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**.
- `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.
- **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`.
## Cross-repo coupling — not a bug
`_constants.py`'s `WORKOUT_LOG_FILE` points at
`~/screen-locker/screen_locker/workout_log.json` — a file owned by the
separate, already-standalone `screen-locker` repo
(https://github.com/kuhyx/screen-locker). This is intentional: the alarm
reads whether today's workout was already logged by screen-locker to decide
whether the morning routine should also lock the workout screen. If this
path ever raises `ModuleNotFoundError`-style confusion, the bug is almost
certainly in the **orchestrator** (`morning_routine` in testsAndMisc), not
here — see the next section.
## The morning_routine orchestrator lives elsewhere
`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.
## Production dependency installation — read this before adding any dependency
`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*
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.
`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.
## 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. It used to be
accidentally committed in the monorepo; it is gitignored here and must
stay that way.
## Commands
- Run tests: `python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100`
- 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 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`
Note the dev venv lacks `gatelock`, so anything importing `_state` (including
`_trigger`) must be run with `/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 or
invocation — see "The morning_routine orchestrator lives elsewhere" above.
- Don't give `wake-alarm.service` an `[Install]` section or enable it. It
fires at login time, not alarm time — see the section above.
- Don't make the trigger start `wake-alarm.service`; it must start
`morning-routine.service` so the workout lock still runs.
- Don't raise the log level of the routine degraded-read paths in `_sync` —
the per-minute tick turns any WARNING there into journal spam.
- Don't commit `wake_state.json`.

1
CLAUDE.md Symbolic link
View File

@ -0,0 +1 @@
AGENTS.md

View File

@ -0,0 +1,100 @@
# Speaker recommendation — bedroom wake alarm
Item 7 of the alarm-hardening work. **Prose only; no code changes here.**
## Why the software cannot solve this
A powered-off speaker is **undetectable from software**. Measured on this
machine: the analog sink is present and `IDLE` whether or not the amplifier has
mains power.
```
$ pactl list short sinks
61 alsa_output.pci-0000_0d_00.6.analog-stereo PipeWire s32le 2ch 48000Hz IDLE
```
Nothing in `_list_real_sinks()` can distinguish "playing to speakers" from
"playing to an amp that is switched off". Designing detection here would be
shipping a placebo. The fix is hardware plus the smart plug that is *already*
integrated.
## The deciding spec is the power switch, not the LED
`_alarm.py` already calls `turn_on_plug()` when the alarm fires and
`turn_off_plug()` from `on_close()` — which runs on every exit path including
SIGTERM. So the "cut mains overnight, restore at alarm time" architecture is
already implemented; it simply has no plug configured yet
(`~/.config/wake_alarm/tapo.json` does not exist).
That makes one criterion pass/fail, ahead of everything else:
> **GATE — the speaker must come back ready to pass audio when mains returns,**
> with no button press and no standby state.
A **latching mechanical** switch (rear rocker, or a volume pot with a click-off
detent) holds its position through a mains cut, so the unit re-energises in the
state it was left in. A **momentary / soft-touch / microcontroller** button has
no mechanical memory and boots into whatever firmware defaults to — usually
standby. That is disqualifying.
**Rule of thumb: a remote control in the box = disqualified.** Anything with a
remote has a standby microcontroller.
## Verified findings (official manuals / vendor FAQs only)
| Model | Switch | LED | Verdict |
|---|---|---|---|
| **Edifier R1280T** ~299–309 zł | Rear latching rocker, **no remote** | Blue, front | ✅ **Top pick** |
| Edifier R1280DB / R1700BT / R1855DB | Has remote → soft standby | — | ❌ see below |
| **Mackie CR3-X** ~ | Rear rocker **+** front click-off pot, no standby | White logo ring, **cannot be defeated** | ⚠️ gate-passes, light fails |
| **M-Audio BX3** | Front click-detent pot (no rear switch) | Front LED | ✅ runner-up |
| **JBL 104** | Rear latching | Front ring around volume knob | ⚠️ auto-standby after 19 min |
| PreSonus Eris E3.5 (non-BT) | Front latching On/Off | Solid blue | ⚠️ ok, but see BT note |
| PreSonus Eris **BT** models | — | Red in standby | ❌ 15-min standby, **up to 30 s wake delay** |
| **SVEN SPS-702** ~239 zł | Rear mains rocker, no remote, analogue pots | Single pinhole | ✅ budget pick |
| Creative Pebble Pro | RGB button cycles to Off | persistence unverified | ⚠️ unverified |
### The Edifier trap, worth stating explicitly
Edifier's own support article (R1280DB/DBS standby): powering down with the
remote activates **standby** — the LED goes off but the physical switch stays
on, and *"the only way to wake the speakers is to use the same remote power
button."* A mains cycle will **not** clear that state. So any Edifier with a
remote breaks the smart-plug architecture. The plain **R1280T has no remote**,
which is exactly why it is the right pick.
### Disqualified on wake latency
PreSonus Eris BT: standby after 15 minutes, and waking costs *"delay up to half
a minute until audio playback is heard."* An alarm that starts half a minute
late is a broken alarm. JBL 104 has the same class of problem (19-minute
standby), though it can be disabled via a headphone-jack toggle sequence.
## Recommendation
1. **Edifier R1280T (~299–309 zł)** — passes the gate (rear latching rocker, no
remote, no standby state to get stuck in), loud enough at 42 W RMS, analogue
volume pot so the level survives a power cycle. Its blue front LED is the
one drawback → cover it with a LED-blackout sticker ("LEDim", biohac.pl, or
any Allegro *naklejki blokujące światło LED*). Cheap, reversible, no warranty
impact.
2. **SVEN SPS-702 (~239 zł)** — budget alternative, same topology (rear mains
rocker, no remote, analogue pots), 40 W RMS, single pinhole LED.
**Avoid** anything with a remote, and avoid Mackie CR3-X for a *bedroom* (its
white logo ring cannot be turned off while powered — vendor FAQ confirms).
## Analog, not USB
Keep the speakers on **analog 3.5 mm**, not USB. A USB speaker disappears from
PipeWire when its mains is cut and re-enumerates on restore, reintroducing
exactly the `auto_null`-only sink race that caused the 2026-07-11 silent alarm.
An analog sink stays present regardless of the speaker's power state.
## Required code change (tracked separately)
`_alarm.py`'s `main()` runs `turn_on_plug()` immediately before
`WakeAlarm(...).run()` — **zero lead time**. An amplifier's soft-start / mute
relay typically delays output 1–5 s after mains is applied, which would swallow
the opening of the alarm. Add a configurable pre-roll delay between plug-on and
the first beep.

View File

@ -27,7 +27,6 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.kuhy.wake_alarm_sync"
// flutter_secure_storage requires API 23+ (Android Keystore).
minSdk = flutter.minSdkVersion