mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +01:00
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:
parent
9379f14779
commit
4286fcb019
@ -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
234
AGENTS.md
Normal 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
234
CLAUDE.md
@ -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`.
|
||||
100
DOCS-speaker-recommendation.md
Normal file
100
DOCS-speaker-recommendation.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user