Commit Graph

5 Commits

Author SHA1 Message Date
801177972b Split _sync.py along the transport/resolution seam item 8 needs
371 -> 168. Token discovery and both backends move to _sync_transport.py;
_sync.py keeps resolution (cache, staleness, fallback), the Alarm dataclass
and the CLI.

The seam is chosen for item 8, not just for line count: that phase adds a
dismissal-record reader, which belongs beside the alarm reader in the
transport rather than inside the resolution chain.

ALARM_CACHE_FILE deliberately stays in _sync. The suite patches
`wake_alarm._sync.ALARM_CACHE_FILE` to keep tests off
~/.config/wake_alarm/alarm_cache.json — the file shutdown-wrapper.sh reads at
poweroff — and moving that binding would silently reconnect the tests to the
real one. That is the failure mode that once wrote 27 fake entries into a
live log while the suite still looked green.

The split broke 31 tests loudly, all of them patch targets that had to follow
the code: the three SYNC_* constants, the two fetchers, the patch.multiple on
the firebase names, and the reload test for the crdt_sync import guard. Every
one is repointed at _sync_transport; none was left silently patching nothing.

Verified after: 465 passed at 100% coverage, `python -m wake_alarm._sync
--show` prints, `--no-cache` still resolves the phone's real alarm through
Firebase, `_trigger --dry-run` prints, and wake_state.json is byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 23:44:24 +02:00
95be57b575 Make the alarm fully automatic and un-weakenable (08:00, earlier-only)
The alarm was opt-in: it only armed when the user tapped "Set Alarm & Sync to
PC", so it could be defeated by simply not tapping it, or by cancelling it the
night before. It is now automatic and there is no lever a half-asleep user can
pull that buys them sleep.

- AlarmPolicy bakes the schedule in at 08:00 daily. The only edit the UI
  offers is moving the NEXT alarm earlier; setEarlier() refuses anything at or
  after the baseline, and refuses to relax an active override. Earlier-only is
  the whole design: every available change costs the user sleep.
- The ratchet resets after each fire, so an early start is one-shot. A one-way
  ratchet with no reset is a trap, not a defence — the first 05:00 would
  otherwise be permanent with no way back short of reinstalling.
- load() re-validates the stored value and ignores anything at or after the
  baseline. SharedPreferences is not a trust boundary.
- No cancel. The Kotlin handler is deleted, not just the Dart wrapper, so the
  capability genuinely does not exist rather than being one invokeMethod away.
- Arming happens in main()'s init seam and via AlarmWatchdog, a daily
  self-rescheduling check that re-arms if nothing is scheduled — closing the
  gap where a force-stop or a failed re-arm goes unnoticed until the morning
  it does not ring. BootReceiver re-establishes it, since setInexactRepeating
  does not survive a reboot.
- Publishing to the PC is automatic too; it used to need the same tap.

PC half, so the two agree with the network down:
- FALLBACK_ALARM_HOUR 13 -> 8, matching the phone's baseline. At 13:00 a
  Firebase outage silently moved the alarm five hours LATER — the one
  direction the design refuses to allow.
- _trigger._clamp_to_baseline() refuses any resolved alarm later than the
  baseline, whatever its source. Without it the PC would faithfully reproduce
  a stale or tampered remote value and the forbidden direction stays reachable
  through sync.

Tests: 465 Python at 100% branch coverage, 79 Dart. Both ratchet directions
are covered, per the standing rule about monotonic counters. test_sync.py and
test_trigger.py were split (payload, token, CLI, clamp) to stay under the
250-line gate; the split lost indirect coverage of extract_hlc/newest_alarm,
which now have direct tests instead.

`python -m wake_alarm._sync --show` and `_trigger --dry-run` both re-run and
still print real output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 23:03:00 +02:00
03392e7ea5 Discover alarm device directories instead of reading a fixed phone path
The PC read one hardcoded path, wake-alarm-sync/devices/phone/alarm.json,
and never parsed the HLC. The phone's device id is migrating from the fixed
constant "phone" to a per-install uuid (a role constant collides as soon as
a second install takes the same role, and a reinstall inherits the previous
install's CRDT identity). A reader pinned to the old path would have stopped
seeing the alarm the moment the phone migrated, fallen through to the cache
and then to FALLBACK_ALARM_HOUR -- i.e. woken at the wrong time, silently.

So the PC half ships first, and this commit is that half plus the phone
change it makes safe:

- _fetch_firebase_alarm() lists SYNC_DEVICES_PREFIX, reads every device's
  alarm.json, and picks the newest by the hour field's HLC rather than by
  listing order. device_id breaks an exact tie 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 with a bad value.
- SYNC_ALARM_PATH stays as a last-resort read for a backend that lists
  nothing, so an un-migrated install keeps working. The GitHub mirror still
  reads the single fixed path.
- The phone persists a uuid under SharedPreferences 'crdt.nodeId' (the key
  todo and home_inventory already use) and pushes under it. deviceId is now
  an instance field, injectable so tests don't touch real prefs.

Parsing and ranking moved to _alarm_payload.py: _sync.py was over the repo's
500-line cap, and the wire format deserves testing without network or disk --
a misparse here doesn't raise, it just resolves to the wrong wake-up time.

Verified against the live RTDB, not just mocks: seeded a uuid-named device
directory and confirmed the reader discovered it, read it and ranked it --
losing to the phone with an old stamp, winning with a new one (03:15 vs the
real 07:45). Test device deleted; --show returns 07:45 and the cache is
intact.

354 tests, 100% branch coverage. Phone app: 10 tests, analyzer clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
2026-08-09 17:06:43 +02:00
66bc3a9e55 Read the phone's alarm from Firebase, keeping GitHub as the mirror
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The seventh and last app on the GitHub-repo transport. fetch_remote_alarm()
is now `_fetch_firebase_alarm() or _fetch_github_alarm()`, so retiring the
mirror later is deleting one branch and three constants.

Two things made this look harder than it was. The first was the standing
"_sync.py is stdlib-only" rule: crdt_sync pulls in requests, and the service
runs /usr/bin/python rather than a venv. But the package already depends on
gatelock and python-kasa, and _smart_plug.py already has the pattern for
exactly this -- a module-scope import inside try/except ImportError. So the
Firebase import is guarded the same way: a dependency that failed to install
degrades the read to GitHub instead of killing the per-minute trigger. The
GitHub path stays stdlib-only, which is what makes that fallback trustworthy.

The second was the shutdown wrapper, which used to do its own authenticated
GitHub read. It could not have worked against Firebase -- an ID token lasts
about an hour and a shell script at shutdown time has no safe way to refresh
one. It now reads the local alarm cache the trigger already keeps fresh
(deployed to /usr/local/bin/shutdown), so the backend is invisible to it.
install.sh also now refuses to run under sudo, which silently skipped its
later steps.

Verified against the live database, not just in tests: the phone's alarm is
already published at wake-alarm-sync/devices/phone/alarm.json, and Firebase
and GitHub both resolve it to 07:45. `_sync --show --no-cache` reports
"07:45 (remote)" and the trigger dry-run behaves unchanged.

338 tests at 100% branch coverage, no new lint suppressions. The backend
tests moved to test_sync_backends.py to keep both files under the 500-line
cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
2026-08-05 09:09:22 +02:00
87d2629e88 Fire the alarm at the phone's time via a per-minute timer
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
The PC is no longer shut down at night, which broke the scheduling model
outright. The wake time used to be decided once, at hibernate, and baked
into the RTC by shutdown-wrapper.sh. With no shutdown there is no resume,
so sleep-hook.sh never ran and nothing fired the alarm at all.

Worse, wake-alarm.service was WantedBy=graphical-session.target and
enabled by install.sh, while _should_run_alarm() gates on day / dismissed
/ workout-logged but has no *time* gate. On an always-on machine that
target is reached on every login, so the alarm fired at login time on any
alarm day — which is every day.

Replace both with a systemd user timer ticking once a minute:

- _sync.py reads the alarm the phone pushed to kuhyx/syncs, mirroring
  shutdown-wrapper.sh's jq parse so the two readers cannot drift. Caches
  each read, then degrades fresh cache -> fetch -> stale cache ->
  FALLBACK_ALARM_HOUR, so a network blip can never cost a wake-up.
  stdlib-only: the units run /usr/bin/python, so a dependency here is a
  production install-path hazard.
- _trigger.py fires morning-routine.service (never wake-alarm.service —
  that skips the workout lock) when local time is inside
  [alarm, alarm + TRIGGER_CATCHUP_MINUTES). The catch-up window covers a
  suspended or just-booted machine; starting an already-active unit is a
  no-op, so an alarm on screen needs no extra dedupe state.
- wake-alarm.service loses its [Install]; install.sh disables it.

A timer rather than a resident scheduler: each tick is a fresh process,
so there is no daemon to die silently and no wall-clock jump to handle
across suspend/resume. Ticks are per-minute for exact-minute firing, but
only hit the network when the cache is older than 5 minutes — ~288
GitHub reads/day, and phone changes land within ~5 minutes.

Degraded reads log at INFO, not WARNING: at one tick a minute a
persistent condition would be 1440 identical journal lines a day. The one
WARNING kept is _trigger firing on a non-phone alarm time.

Token resolution gains a fallback to the sibling apps' repo-scoped
sync_token, in both readers, so one PAT covers every app on kuhyx/syncs.

Note this surfaced that wake-alarm-sync/ does not exist in kuhyx/syncs at
all: the phone has never completed a push since the crdt_sync migration,
so the hibernate path had been silently using the +8h fallback. Fixing
that is a phone-side action (reconnect GitHub with repo scope, then one
"Set Alarm & Sync"). Alarms edited directly in the Clock app still do not
reach the sync — that needs an ACTION_NEXT_ALARM_CLOCK_CHANGED receiver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 20:21:17 +02:00