diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ad474f5..adb01a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..322683a --- /dev/null +++ b/AGENTS.md @@ -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//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 "`, 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`. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 5b5fab6..0000000 --- a/CLAUDE.md +++ /dev/null @@ -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//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 "`, 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`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/DOCS-speaker-recommendation.md b/DOCS-speaker-recommendation.md new file mode 100644 index 0000000..2d4e388 --- /dev/null +++ b/DOCS-speaker-recommendation.md @@ -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. diff --git a/phone_app/android/app/build.gradle.kts b/phone_app/android/app/build.gradle.kts index 0cd3631..a8cd6f6 100644 --- a/phone_app/android/app/build.gradle.kts +++ b/phone_app/android/app/build.gradle.kts @@ -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