The PC no longer wakes anyone -- the phone does. This deletes the alarm
window, the audio escalation, the sink/reactivation machinery, the power-key
inhibitor, the escape hatch, the curtains, the smart plug, the brightness and
DDC/CI control, the hibernate path (shutdown wrapper + rtcwake + sleep hook)
and the challenge set: 30 modules, 34 test files, ~5k lines.
What stays is the part that still earns its keep: the per-minute trigger
reads the alarm the phone published and starts morning-routine.service, so
the workout lock still runs at the right time.
THE ONE LOAD-BEARING BEHAVIOUR CHANGE. Deleting _alarm.py orphaned the only
writer of wake_state.json, and _trigger gated on it via
was_alarm_dismissed_today(). With that gone -- and was_workout_logged_today()
already permanently False, since WORKOUT_LOG_FILE does not exist -- both
gates would have been dead and the routine restarted on every tick for the
rest of the 60-minute catch-up window. The unit is Type=simple, so a start
while it runs is a no-op, but the moment the workout lock finished the next
tick would relaunch it on top of the user. So _trigger now writes its own
once-per-day latch (save_trigger_fired / was_trigger_fired_today) and gates
on that. Collapsing the catch-up window instead was rejected: that window is
the entire reason a suspended or just-booted machine still fires. It also
replaces the deleted sleep hook, via Persistent=true.
_trigger also imported should_refire from _alarm_session, which was on the
delete list. Since start_session lived only in _alarm.py, no session record
is ever written again and the branch was unreachable, not merely unused.
WAKE_STATE_FILE is now redirected in tests/conftest.py rather than per-file.
The trigger tests never redirected it, because until this commit nothing on
that path wrote anything -- and a per-file redirect is exactly the kind that
gets forgotten when a new writer appears, with a silent failure: a green
suite that quietly stamped the live state file. Verified by mtime that it
did not.
Cross-repo, same change: morning_routine._orchestrator loses its alarm leg
and its --with-alarm flag. Leaving an orchestrator pointing at a deleted
module caused a month-long silent failure once already.
uninstall.sh is new, and matters more than it looks. /usr/local/bin/shutdown
is root-owned and shadows /usr/bin/shutdown; deleting this repo would leave
it in place, and with the alarm cache gone it falls through to rtcwake +
hibernate -- so "shutdown now" would silently stop being a poweroff, every
day, forever. /etc/sudoers.d/wake-alarm is deliberately NOT removed:
screen-locker's own _wake_alarm.py still needs passwordless rtcwake.
159 tests pass at 100% branch coverage; all 26 pre-commit hooks green.
Verified by running it: _sync --show and _trigger --dry-run both resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
The dismiss gate was an OR — NFC tag *or* QR *or* (25 steps + 2 min) —
followed by one challenge. QR never worked at ring time (no scanner
package, no CAMERA permission, `onQrScanned` had no caller), so the real
bar was "walk 25 steps, wait 2 minutes, answer one question" and the NFC
tag was already fully bypassable.
It is now a strict, latched sequence owned by `DismissGate`:
tag 1 -> 25 steps + 2 min -> tag 2 -> 3 challenges
Every stage is required, in that order, and a stage once passed never
reverts. That last part also fixes a real bug: `_onNfcTag` and `_trySteps`
both assigned the room-proof result straight onto `_inRoom`, so a wrong
tag tapped after the gate had passed sent the user back to the start.
Two *distinct* tags are required; the same id in both slots is refused at
registration, since one tag satisfying both stages removes the walk that
is the whole point. The existing single-tag registration migrates into
slot 1 rather than being stranded.
Answers are cumulative, not consecutive: a wrong one costs the attempt and
nothing else.
The bypass is the only fallback and is deliberately narrow. After 5 minutes
of ringing, or immediately when NFC is off or fewer than two distinct tags
are registered, the two *tag* stages are skipped and the count rises to 6.
The walk is never skipped — jumping straight to the challenges would let
someone lie in bed for five minutes and answer questions without standing
up, which is weaker than the gate this replaces. Without some such hatch a
dead tag at 06:00 leaves an alarm that cannot be switched off at all.
Also deletes the `flash` challenge outright, along with the now-orphaned
`generateCode`/`kDismissCodeLength`/`_dismissChars`. It was the only kind
that removed its prompt from the screen, which is unsolvable for someone
who has just walked in from another room.
The ring screen takes an injectable clock: `Stopwatch` reads the wall
clock, which `tester.pump()` does not advance, so without that seam no
widget test could reach the two-minute floor or the five-minute bypass.
Verified on the Pixel 6a: the existing tag migrated into slot 1 and both
rows render. 102 Dart tests pass; analyzer clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
Already red before today's Python work touched the repo; the commit hook had no
reason to look at requirements.txt, so only the CI mirror's whole-tree run saw
it. Bumped across every repo pinning 7.15.4 in the same pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading against 3.10/3.11 -- three tools judging an interpreter
nothing runs any more, and ruff withholding the pyupgrade fixes that only
become safe at the newer target. `pin_ci_python.py` rewrites these three knobs.
What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC`
is now correct (UP017), `socket.timeout` where `TimeoutError` is (UP041), and
ruff's stable formatter applying PEP 758 -- `except (A, B):` becomes
`except A, B:`. That makes the source 3.14-only at the syntax level, which is
what `requires-python = ">=3.14"` already promised; kuhy chose it deliberately
over holding ruff a release back (2026-08-29).
ruff, ruff-format, mypy --strict and pylint all clean at the new target.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
Two CI failures, both real, neither visible locally.
**`python-kasa==0.10.2` requires Python >= 3.11** and the test matrix still
ran 3.10, so `pip install -r requirements.txt` could not resolve there. The
floor moves to 3.11 across `requires-python`, mypy and pylint, and the matrix
becomes 3.11/3.12/3.13. Nothing runs 3.10 -- production is system Python 3.14
-- so this documents what was already true rather than dropping support.
**The `pre-commit` workflow never checked out `kuhyx/utils`,** so both
delegating hooks exited 1 with "shared gate not found at
/home/runner/utils/...". That workflow has been red on every run since those
hooks were added; the freshness hook just made it two failures instead of one.
It now checks the shared repo out to `.utils` and points `UTILS_ROOT` at it,
the same way every other workflow in the fleet does.
465 tests at 100% branch coverage in a clean venv; gate exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
`.fvmrc` says 3.47.2 and nothing read it: `subosito/flutter-action` takes its
own `flutter-version:`, and the fleet had drifted to 3.44.6, 3.44.8, 3.44.9,
3.47.0 and 3.47.1 with no one tracking it. kuhylog is the proof it matters --
its runner on 3.44.9 predates the `@awaitNotRequired` annotation, so the same
`unawaited(Navigator.push(...))` is a violation on one SDK and required on the
other, and CI went red on a commit that is clean locally.
The freshness gate reads these pins now (utils@98213cb), so this cannot drift
again in silence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
**The two shared Python libraries were four minor versions behind and the gate
could not see it.** They are PEP 508 direct references
(`gatelock @ git+...@gatelock-v0.4.1`), which parsed as unpinned PyPI packages
that do not exist on PyPI -- an undeterminable answer, not a stale one.
utils@7a682a4 teaches the gate that shape; this repo is the first to benefit:
gatelock v0.4.1 -> v0.7.2, crdt-sync v0.5.1 -> v0.9.1. The system install was
already on 0.7.1/0.9.0, so only the manifests were adrift.
Python side: bandit 1.7 -> 1.9.4, mypy 1 -> 2.3.1, pylint 3 -> 4.0.7,
pytest 8 -> 9.1.1, pytest-cov 4 -> 7.1.0, pytest-randomly 3 -> 4.1.0,
ruff 0.8 -> 0.16.5, python-kasa 0.7 -> 0.10.2, and the rest exact-pinned.
Five majors and not one lint or type finding: `scripts/ci_mirror.sh` builds a
clean venv from requirements.txt and runs the whole CI set, 465 tests at 100%
branch coverage.
phone_app takes crdt_sync_dart v0.11.1 and the flutter_secure_storage 11 chain
(compileSdk 37 / AGP 9.3.2 / Gradle 9.7.1). 79 tests green, analyzer clean,
release APK builds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
Option B was taken. All seven oversized files are split, `pre-commit
--all-files` passes clean, and the 39 commits are on origin/main with no
--no-verify.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
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
329 -> 207. The proxies are pure plumbing over already-built widgets —
nothing in them constructs Tk, which is what makes them separable from the
builders that do.
One trap the split surfaced: `tk` had to stay a RUNTIME import in the new
module, not a TYPE_CHECKING one. `tk.TclError` is caught in a suppress()
there, so deferring the import raised NameError the first time a widget call
failed — i.e. exactly the path these proxies exist to swallow. 48 tests
caught it, loudly.
465 passed at 100% coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
317 -> 143. EscapeDialog and _FormFields move to _escape_dialog.py; the
policy (whether an escape may be offered, and the budget it spends) stays.
ESCAPE_HISTORY_FILE deliberately stays imported in _escape_flow: conftest
patches `wake_alarm._escape_flow.ESCAPE_HISTORY_FILE` to keep the suite off
the production budget, and moving that binding would silently reconnect the
tests to the real file. EscapeDialog is re-imported for the same reason,
guarded by __all__.
Two things the split broke loudly, both worth noting:
- The test patched `wake_alarm._escape_flow.tk`, which after the move
intercepted nothing; repointed at _escape_dialog.
- The gatelock imports the dialog needs (escape_text_tab_trap and friends)
did not come across, and ruff then stripped TYPE_CHECKING before my
annotation block could use it — so the import and its first usage had to be
added in the same edit, exactly as the standing rule says.
465 passed at 100% coverage; both documented CLIs still print real output.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
_sinks.py 341 -> 249: the pactl *parsing* moves to _sink_parsing.py. That half
is pure text handling with no subprocess and no side effects, so it is
testable without touching the host's audio; _sinks.py keeps command execution
and the activate/restore orchestration.
The parsers are re-imported into _sinks under their original names, guarded by
__all__. The suite patches `wake_alarm._sinks._parse_cards` and friends, and a
moved symbol makes patch() stop intercepting *silently* — the test then
asserts nothing and still passes. Ruff proved the point mid-split: shortening
__all__ let it delete two of the re-exports and broke an import outright.
_parse_profile_line is only imported (never patched), so its test now imports
from the new module instead.
home_screen.dart 270 -> 249. This one was mine — the automatic-alarm change
pushed it over and I claimed in TODO-push-and-gate.md that nothing this
session touched was over the limit; that was wrong. Publishing moves to
AlarmPublisher, which returns a status line instead of calling setState, so it
is testable without a widget. _loadSavedTime became _loadSyncState: it no
longer loads a *time*, because the schedule is baked in and owned by
AlarmPolicy, so a stored value could only disagree with it.
465 Python at 100% coverage, 79 Dart, APK builds, and both documented CLIs
still print real output.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
314 lines, over the 250 gate. The UTC day key and the save/load round-trip
stay; the read-side queries (workout skip, and the two "already done today"
gates the trigger consults) move to test_state_queries.py.
Checked the trap this file is shaped for: wake_state.json is real on-disk
state that screen-locker mirrors, so a lost redirect would have the suite
writing to it while still looking green. Its mtime and md5 are unchanged
across the split (Aug 24 07:01, 19bf741a...).
465 passed, 100% coverage, unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
351 lines, over the 250 gate. Config loading and connection stay; set_state,
run and the kasa import fallback move to test_smart_plug_ops.py. The whole
preamble is carried, not just the imports — the module-level fixtures live
after them and a header-only copy would error every moved test.
465 passed, 100% coverage, unchanged from before the split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
The push is blocked by the pre-push ci-mirror hook failing the 250-line gate
on seven Python files that were ALREADY over before this session (verified:
the identical set fails at 0e8da72). Nothing this session touched is over —
where a change pushed a file over, it was split in the same change.
--no-verify is banned by git-rules.md and splitting untouched files is banned
by the standing rule that cost a full revert once, so this is a decision for
the user rather than something to route around. The prompt lays out the three
options and the traps that apply to the split path.
Also records what is already verified on hardware so a fresh session does not
redo it, and the three things still genuinely open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Measured on the Pixel via the muted-stream mask in `dumpsys audio`:
STREAM_ALARM is unmuted under priority DND (zen_mode=1, mask 0x1a6) and
MUTED under total silence (zen_mode=2, mask 0x9be). The verified ring was
priority DND, so "STREAM_ALARM ignores DND" was true but overstated — no
audio flag escapes total silence, and the fix if that gap ever matters is a
zen-policy check at arm time, not an audio change.
Also records that raiseAlarmVolume() genuinely runs on this hardware
(observed raising oldIndex:0 -> 7, no SecurityException), so it recovers a
stream turned down to zero but not one muted by total silence.
Two probe-harness traps kept: --build-number produces a non-running APK, and
the arm probe can register for TOMORROW because main.dart's rearm()
recomputes nextOccurrence after the armed minute has passed.
Folded in from scratch notes left mid-session (.dnd-findings/,
TODO-verify-dnd-ring.md), both now deleted — the verification they asked for
has since been done.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Armed itself at 08:00 with zero taps (RTC_WAKEUP, window=0, exact), the
earlier-only ratchet rejected a later time on-device without weakening the
armed alarm, and the daily watchdog is scheduled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
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
The dismiss path is no longer "covered by widget tests": the user ran it and
confirmed it. tag discovered: 0465dab3ce2a81 on the ring screen, challenge
solved, alarm silent.
Records the four defects that only a hardware run could have found — the
onPause reader-mode teardown, the cumulative step sensor that could never move
off 0/25, the ON_LOCKED NFC gating, and the self-hiding flash challenge — and
the two things still genuinely unverified: the QR scanner UI (which does not
exist) and the locked-keyguard dismiss (implemented, never run to completion).
Also records the process lesson, because it cost the most time here: after
hardware confirms a path, the next change must be the smallest delta on that
exact state, never a restructure bundled with a fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Two things, one of them an undo of my own change.
The flash challenge is gone from the phone's rotation. It hides the code after
kDismissFlashSeconds, and on the phone that is unsolvable in practice: the ring
screen is reached by walking to another room and tapping a tag, so the code has
already vanished by the time the user is looking at it. Reported from the phone
as "invisible - impossible to actually do". Every challenge the alarm shows now
stays on screen until it is answered, and a test asserts next() never returns
one that hides itself. The constructor stays for the PC half's parity tests;
nothing routes to it.
The two-channel split is reverted. I introduced it to fix a tag reaching Kotlin
but not Dart, and pointed the push channel at the call channel — which broke
NFC *and* the step counter, both of which had been confirmed working on the
phone one build earlier. The user caught it: "they worked previously so your
changes regressed it". The split was a theory that was never shown to be
necessary; the single channel from 08b44b4 is restored unchanged.
Lesson worth keeping: when hardware has just confirmed a path works, the next
change should be the smallest possible delta on top of that exact state, not a
restructure bundled with a fix.
flutter test: 70 pass. dart analyze lib/ test/: clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Three claims are now measured rather than assumed: the alarm fires from Doze
on time, wakes the screen over the lockscreen, and rings through DND with
zen_mode=1 (player state:started on USAGE_ALARM/STREAM_ALARM(4)).
The dismiss is still unverified. Three defects were found and fixed during the
attempt, but no fix has been confirmed on hardware — the run ended before a
successful tap. Recorded as unverified rather than implied by the commit that
fixed them.
Also recorded: requestDismissKeyguard raises the PIN prompt rather than
silently unlocking, so a no-touch NFC tap was never achievable on a secured
phone. Better stated now than discovered at 06:00.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
All three were invisible to the emulator (no NFC, no step sensor) and to the
widget tests (which mock the platform channel), and each one on its own made
the alarm harder or impossible to dismiss.
1. AlarmActivity.onPause called NfcReader.stop(). enableReaderMode is
per-activity but disableReaderMode is effectively global, so anything
briefly coming in front of the ring screen killed NFC polling for the rest
of the ring. Measured on the Pixel: `flags: 271` immediately followed by
`flags: 0`. Teardown moves to onDestroy, and onResume re-asserts.
2. StepCounter could never report progress from a standing start.
TYPE_STEP_COUNTER is an on-change *cumulative* sensor: it emits nothing
until the user has already walked, and the first emission was being
consumed as the baseline — so the ring screen sat at 0/25 no matter how far
you walked. TYPE_STEP_DETECTOR is now primary (one event per stride, no
baseline to miss); the counter stays as the fallback for devices without a
detector.
3. NFC cannot be read while the ring shows over the lockscreen. Measured
A/B on the Pixel with the same tag: registration while unlocked →
mScreenState=ON_UNLOCKED, tag discovered; ring screen over the keyguard →
mScreenState=ON_LOCKED, reader mode accepted (flags 271) but no discovery.
Android gates NFC polling on lock state. showWhenLocked draws *over* the
keyguard without unlocking, so the alarm now calls
requestDismissKeyguard() on fire and re-asserts reader mode from the
dismiss callback. User's call, taken explicitly: on a secured phone this
surfaces the PIN prompt, which is an acceptable trade for an alarm you
must get up for.
Without (3) the only satisfiable proof at 06:00 is steps, which is exactly
the single-mechanism dependency the room-proof redundancy exists to prevent.
flutter test: 69 pass. dart analyze lib/ test/: clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
The gate runs --all-files at push time, so it caught two files this session
pushed over: AGENTS.md (244 -> 330) and home_screen.dart (238 -> 269). Both
are mine; the pre-existing Python overages are untouched, per the standing
rule not to split a file this change does not already have to edit.
- The phone section moves to DOCS-phone-alarm.md, following the repo's own
pattern (DOCS-architecture.md, DOCS-vmbox.md), leaving a pointer.
- Alarm arming moves out of home_screen.dart into AlarmArming — "what it
takes to arm an alarm" was never the picker's concern, and the extraction
also repaired a doc comment my earlier edit had orphaned onto the wrong
method.
flutter test: 69 pass. dart analyze lib/ test/: clean. APK builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
NFC now works end to end (register in Settings, tap to satisfy), so item 3's
remaining piece is the QR scanner UI alone — the third mechanism, not a
missing one.
Two honest gaps recorded rather than glossed: no real alarm has ever rung on
the Pixel, and the dismiss path has never run on any device because the AVD
has neither a step sensor nor NFC. Widget tests cover it; a real tag tap does
not yet.
Also noted: mute the host before firing a ring on the emulator — it routes
STREAM_ALARM to the PC speakers and raiseAlarmVolume() sets it to max.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Two defects that each defeated the alarm in a different way:
1. Nothing finished AlarmActivity after a correct answer. Sound stopped and
the alarm re-armed, but the dismiss screen stayed up over the lockscreen
forever — back is disabled in both halves by design — and the live instance
kept _inRoom=true with the solved answer still typed, so the next ring
could reuse an already-passed gate. AlarmActivity now handles
closeRingScreen; the shared AlarmChannel deliberately holds
applicationContext and cannot finish an activity, hence the split handler.
2. Nothing could write roomProof.nfcTagId — there was no registration UI — so
checkNfc always answered "no tag registered" and checkQr was never called.
Functionally that left steps as the only satisfiable proof, which is
exactly the state the decision rules out ("NFC or QR qualifies; steps alone
does NOT") and removes the redundancy whose purpose is that one failing
mechanism at 06:00 must never block dismissal. Added RoomProofScreen
(reader mode from MainActivity, since registration needs an Activity) and
linked it from Settings.
Also: StepCounter kept one listener instead of registering a fresh one per
ring and never unregistering, and the ring screen drops its NFC handler on
dispose.
New tests cover what had been dead code: a registered tag satisfies the proof
and reveals the challenge, a foreign tag does not, and a correct answer
invokes BOTH dismiss and closeRingScreen. The emulator cannot reach the
dismiss path at all (no step sensor, no NFC), which is why it is covered by
widget tests rather than claimed from the AVD run.
Documented ringNow/main_ring_probe.dart in AGENTS.md as a deliberate manual
test handle, the phone-side equivalent of wake-alarm.service.
flutter test: 69 pass. dart analyze lib/ test/: clean. pre-commit: passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Items 1 and 4 ship and are emulator-verified. Item 3 has NFC and the
steps+elapsed fallback wired into the ring screen; the QR scanner UI is the
remaining piece and is now the top entry under "Remaining".
Records what has NOT been verified rather than letting the deploy stand in for
it: the ring has only ever fired on the API 36 AVD, never on the Pixel, and
never with DND on — which is the one condition the STREAM_ALARM claim rests on.
Four new traps, all of which cost time here: the plan's LogStore.changes does
not exist (so re-arm needs three explicit hooks), the Android SDK was never
missing but merely unexported, a manifest permission is not a granted
permission, and a debug probe with its own UI proves nothing about the route
it was written to test.
TODO-phone-alarm-session.md deleted: its work is done, and a finished task
file must not be left behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Three defects found by reviewing the emulator run, all on the dismiss path:
1. ACTIVITY_RECOGNITION was declared but never requested at runtime (API 29+).
Denied, TYPE_STEP_COUNTER delivers nothing, so checkSteps() reports "sensor
unavailable" forever. With steps the only wired proof, that made the alarm
*undismissable*, not merely degraded. MainActivity now requests it
alongside POST_NOTIFICATIONS.
2. checkNfc/checkQr were dead code — no reader, no channel method, no
registration — so steps was the only reachable proof. That inverts the
decision ("NFC or QR qualifies; steps alone does NOT") and removes exactly
the redundancy whose purpose is that one failing mechanism at 06:00 must
never block dismissal. Added NfcReader (hand-written on the existing
channel, NfcAdapter.enableReaderMode so the tap does not restart the
activity and reset the challenge) plus RoomProofSettings for the registered
tag/QR. mobile_scanner stays the droppable extra.
3. StepCounter blocked the platform main thread up to 3s per read, called from
the MethodChannel handler — jank-to-ANR on the ring screen. Now registers a
listener and serves the last sample; reads never block.
Also: raiseAlarmVolume() is guarded. setStreamVolume can throw under a zen
rule, and by then startForeground() has already posted — a failed volume bump
must not take the ring down with it.
Verified on the API 36 AVD with the real app widget and route (the earlier
probe rendered its own UI, so it proved the Kotlin path but nothing about
/ring): screen Asleep -> Awake, AlarmActivity top resumed over the lockscreen,
RingScreen showing the room-proof gate. New widget tests cover the /ring route
resolving and the challenge staying gated behind the room proof.
flutter test: 57 pass. dart analyze: clean. Zero Python files touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Dart side of the alarm (items 1, 4, 3). main.dart had no init seam at all;
it now has ensureInitialized(), a navigatorKey and a /ring route.
- challenge.dart: all five kinds ported from _challenges.py so both halves
behave identically. kDismissCodeLength/kDismissFlashSeconds are duplicated
from _constants.py, not synced — noted in the file, since Dart cannot read
Python.
- alarm_scheduler.dart: the Dart side of the single MethodChannel.
- ring_screen.dart: room proof first, then a challenge, then dismiss. Back is
a no-op (PopScope) so the alarm cannot be escaped.
- room_proof.dart: NFC or QR satisfies on its own; steps NEVER do alone —
the phone is stationary while you walk to it, so steps only accrue after
pickup and can be faked by shaking it in bed. Steps count only together
with elapsed time, as the fallback when both others fail.
- home_screen: arms the app's own alarm instead of asking the user to confirm
in the Clock app (item 1's "no user interaction"). SET_ALARM stays as
redundancy with SKIP_UI=true.
Re-arm has three hooks and needs all three: startup, BOOT_COMPLETED, and
dismiss. The plan assumed LogStore.changes would cover this; that stream does
not exist in crdt_sync_dart (verified — the library has no Stream at all), so
the picker's save is only one hook and a restart would otherwise arm nothing.
Fixed a real gap the API 36 AVD exposed: POST_NOTIFICATIONS was never
requested at runtime, so a fresh install sat at importance=NONE and the
full-screen intent was rejected outright — the alarm would ring with no
dismiss screen. MainActivity now requests it, and canUseFullScreenIntent()
also checks areNotificationsEnabled() so the check gates rather than warns.
Verified on the API 36 AVD: screen Asleep -> Awake, AlarmActivity became the
top resumed activity over the lockscreen, service isForeground=true
types=0x2 (mediaPlayback) category=alarm, no crash. flutter test: 54 pass.
dart analyze: clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Hand-written Kotlin behind ONE MethodChannel, never a plugin: AGP 9.0.1 /
Gradle 9.1 / Kotlin 2.3.20 is bleeding edge and a plugin that will not compile
breaks the build outright rather than degrading.
- AlarmScheduler: setAlarmClock() (Doze-exempt, no SCHEDULE_EXACT_ALARM
prompt), persists the time so boot and dismiss can re-arm.
- AlarmReceiver: hands straight to the service — a receiver gets ~10s and no
wakelock, so nothing that must outlive it belongs there.
- AlarmService: foreground service, PARTIAL_WAKE_LOCK, USAGE_ALARM
(= STREAM_ALARM, which is what ignores DND and silent mode), looping
ringtone, vibration, full-screen intent over the lockscreen.
- AlarmActivity: showWhenLocked + turnScreenOn, routes to the Dart ring
screen, back is a no-op so the alarm cannot be escaped.
- BootReceiver: re-arms on BOOT_COMPLETED; AlarmManager drops alarms across a
reboot.
- StepCounter: TYPE_STEP_COUNTER baseline for item 3's fallback proof, also
hand-written to keep plugin risk off this path.
Details that are a hard crash or a silent no-ring rather than a warning:
FLAG_IMMUTABLE on every PendingIntent (mandatory since API 31);
foregroundServiceType declared on the <service> AND passed to
startForeground(); explicit android:exported on every component;
USE_FULL_SCREEN_INTENT declared (missing from the plan's list) and
canUseFullScreenIntent() exposed so the Dart side can gate rather than warn.
Every permission verified present in android-36's api-versions.xml rather
than from recall. Compiles green with `flutter build apk --debug`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
All three were delegated to the Flutter Gradle plugin, so there was no floor
to reason about — and full-screen-intent and foreground-service semantics both
changed at API 34/35, which is exactly the code the alarm path needs.
- compileSdk/targetSdk = 36, matching the target phone.
- minSdk = 28: the floor for android:showWhenLocked / android:turnScreenOn as
plain manifest attributes (no legacy branch), which also clears
flutter_secure_storage's API 23 and Flutter's own default of 24.
Verified with `flutter build apk --debug` (exit 0).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Items 4/3/1 are their own session: new Kotlin on AGP 9 / Gradle 9 / Kotlin
2.3, an emulator that is not installed yet, and physical-device verification
at the end.
Records two prerequisites measured today rather than assumed: the app's
min/target/compileSdk are all still delegated to the Flutter plugin (no floor
to reason about full-screen-intent or foreground-service semantics), and only
/usr/bin/adb is on PATH — emulator, avdmanager and sdkmanager are absent, and
the image needs to be API 36 to match the phone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Curtains move to Done. Remaining is the phone chunk, then gating, then the
black screen.
Two new traps, both paid for this session:
- Do not split a file the change does not already have to touch. The gate
fires on staged files, so editing _sync.py just to repoint an import pulled
its 371 lines in and cascaded into five modules and a broken tree. Put a new
constant in its single consumer instead.
- Extracting a __main__ CLI silently broke `python -m wake_alarm._sync
--show`: it printed nothing and exited 0, which every linter and the whole
suite passed straight over.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
_curtains.py: a two-method driver protocol, a registry, and a logging no-op
default. prepare_hardware() calls open_curtains() next to turn_on_plug().
The no-op is not a placeholder awaiting hardware -- it is the correct
behaviour on a machine with no motor. Shipping it now means the call site and
the JSONL event are real, so buying a motor later is a config file rather than
a code change.
Shaped like _smart_plug.py, which solves the same problem: absent or malformed
config means "no curtains" rather than an error, an unknown driver name
degrades with a WARNING, and a driver that throws is caught -- a jammed motor
must never be the reason nobody is woken. The call sits on the startup path,
ahead of the first beep, so an exception escaping it would take the alarm down
entirely.
Why bother at all with no hardware: the audio failsafe silences the alarm at
FAILSAFE_SILENCE_SECONDS, and daylight is the one wake cue that outlives it --
and the only one independent of a sink, a speaker or an HDMI handshake, which
is the exact chain that made this alarm silent in the first place.
DOCS-curtains-hardware.md has the buy list, the three rules a new driver must
follow, and what is deliberately excluded (no cloud, no scheduling of its own,
no close-at-night -- closing belongs to the night lockdown, item 13).
CURTAINS_CONFIG_FILE and CURTAINS_DEFAULT_DRIVER live in _curtains.py rather
than _constants.py, since this is their only consumer -- the same call
_rounds.py and _alarm_session.py already make. That also keeps _constants.py
under the 250-line cap without dragging _sync.py's pre-existing overage into
this commit; splitting _sync.py belongs to the phase that legitimately touches
it (item 8).
465 tests pass, 100% branch coverage. vmbox harness 8/8, exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Items 6, 2 and 10 move to Done. Remaining is the phone chunk (1/4/3), then
gating (8), then curtains (5) and the black screen (13).
Adds three traps this session paid for: vm run's exit code fails a gate open,
_state.py's UTC key is load-bearing across repos and must not be "fixed", and
ruff's autofix will silently gut a function whose only effect is printing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
scripts/vmbox/harness.sh provisions a disposable Arch guest, runs the REAL
install.sh in it, and adjudicates 8 cases on a host-side exit code. Plus
DOCS-vmbox.md, which states what a pass does and does not mean.
Covered: system-python imports, timer enabled, wake-alarm.service still
`static` (an [Install] there fires the alarm at *login*), the shutdown wrapper
shadowing /usr/bin/shutdown, the alarm-cache shape jq expects, a trigger tick,
the inhibitor, and the re-fire path. With --window it also opens the real
fullscreen Tk window and screenshots it from the host -- confirmed showing
"WAKE UP!", round 1/4 and a live flash challenge.
Not covered, and said plainly in the doc rather than implied away: audio (the
guest has no pactl/paplay), the phone, GPU and real outputs, and anything
bootloader-related (guest is BIOS, this machine is UEFI).
**The VM earned its place immediately by finding two live bugs that the host
could not reach and the unit suite passed straight through.**
1. The inhibitor claimed a block it did not have. Popen succeeding only means
the binary launched; logind can still refuse -- the guest's polkit denies a
non-interactive session outright ("Failed to inhibit: Access denied", exit
1) while the alarm cheerfully logged the power key as blocked.
take_power_key_inhibitor() now waits INHIBIT_CONFIRM_SECONDS and confirms
the child survived, returning None and logging the refusal otherwise.
2. An unsigned session record was written and then rejected on read. With no
HMAC key on the machine (/etc/gatelock absent, as in the guest),
verify_entry_hmac returns False for a *missing* key exactly as it does for
a forgery -- so every session record was unreadable and item 2's power-off
defence silently did not exist. An entry with no "hmac" field is now
honoured with a loud warning; a present-but-wrong signature is still
rejected, since that one really is tampering.
Two harness traps worth recording, both hit while building it:
- `vm run` reports the *VM's* verdict, not the guest command's exit code, so a
guest script exiting 1 still gives rc=0. A gate built on that fails OPEN.
guest_cases.sh prints ALL-CASES-PASSED only when every case passed and the
harness greps for it; verified by breaking a case on purpose and watching
the gate go red.
- `vm share`/`vm repo` key off the path basename, so REPO_DIR must be absolute
and symlink-resolved. A relative "scripts/vmbox/../.." makes rsync write
into /home and fail with permissions errors that look like a guest fault.
Guest checks live in real .py files beside the shell, not heredocs, so they
get linted like everything else -- ruff's autofix gutted an early heredoc
version (stripped the assignments AND the print) exactly as TODO-hardening
warned, and only running it showed the file had stopped producing output.
Also drops three stale fan references the guest install run surfaced:
install.sh still announced "Fans will ramp to 100%" and README.md still
described the fan ramp, three commits after item 14 deleted the feature.
Splits carried: test_inhibit.py -> + test_inhibit_cleanup.py,
test_alarm_session.py -> + test_alarm_session_refire.py.
455 tests pass, 100% branch coverage. Harness: 8/8 cases, exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Two defences, because one press and one held press are different problems.
**Prevention — _inhibit.py.** A logind block-mode inhibitor on
handle-power-key:shutdown:idle, taken while the alarm is audible and released
in on_close(). Confirmed on the host before writing it that
PowerKeyIgnoreInhibited=no is in force, so no logind.conf drop-in is needed --
and none is added: a drop-in changes the machine permanently, an inhibitor is
scoped to the alarm.
Verified live: the inhibitor appears in `systemd-inhibit --list` as
`wake-alarm ... shutdown:idle:handle-power-key ... block` and disappears on
release.
The child runs `sleep 7200`, never `sleep infinity`. An orphaned block-mode
inhibitor would hold the power button down until reboot -- worse than the bug
being fixed -- so it self-terminates, and a stale one is cleared at startup.
That cleanup was wrong on the first attempt and the host run caught it:
`pkill -f "--who=wake-alarm"` matched the *shell that had just typed the
pattern* and killed it, leaving the real inhibitor alive (`killed 0`, one
inhibitor still listed). Substring matching is inherently self-matching. Now
it lists `pgrep -x systemd-inhibit` and confirms each pid via
/proc/<pid>/cmdline. Re-verified: orphan reaped, exactly one live inhibitor,
zero remaining after release.
**Consequence — _alarm_session.py.** The real gap was that nothing on disk
ever said "ringing, not yet dismissed": wake_state.json is written only on
dismissal, so a hard power-off left no trace and the catch-up window closed
while the machine was down. A signed record is now written at fire time, and
_trigger re-fires on one still marked ringing, ignoring the window.
Deliberately no date field. _state.py keys by UTC and
screen_locker/_wake_state.py mirrors that helper to read the very same file
from another repo, so the UTC basis there is load-bearing. The plan's
_today_str() flip is therefore NOT done -- it would have made any dismissal
between 00:00-02:00 local read as yesterday's in screen-locker and silently
lose an earned workout skip. This record carries only an aware fired_at and
compares elapsed time, so the midnight question never arises.
Age-bounded at 5400 s: an alarm that dies before dismissal leaves a record
nothing ever clears, and unbounded this would restart the alarm every minute
forever.
Two bugs the unit tests could not see, both found by running it:
- A future-stamped record returned None, i.e. "not ringing". But a backward
clock jump is routine on a machine that hibernates nightly (NTP correcting
after resume), so that failed *open* on exactly the escape it closes: ring
on a fast clock, clock corrects, power-off works again. Now clamped to zero
within a 900 s tolerance and logged.
- _alarm.py derived the correlation id from the alarm's *start* instant. On a
catch-up or re-fire the PC would write wake-...-0735 while the phone wrote
wake-...-0700 and the two log streams stop joining. Now
correlation_id_for_today() derives it from the resolved alarm time, which is
what _correlation.py always specified.
Every trigger test patched should_refire, so _decide against a real record was
untested -- which is why only the manual run found the first one. There is now
an end-to-end test that writes a real session and asserts the decision.
Splits carried: test_trigger.py 294 -> 197 + test_trigger_main.py,
test_alarm_part2.py 266 -> 170 + test_alarm_timers_flash.py. The inhibitor
handle moved into _AlarmHardware, which also settles pylint's attribute count
and puts it beside the other thing on_close() gives back.
No new suppressions: the one inline pylint disable was redundant, since
consider-using-with is already disabled repo-wide in pyproject.toml.
452 tests pass, 100% branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
_reactivate.py shipped in phase 2a with tests but nothing called it, so the
HDMI sink fix was never actually in the alarm's path. WakeAlarm.__init__ now
starts the retry next to _activate_all_outputs(), stopped by the same
_stop_beep event the beep loop watches.
Verified on the host, not just in tests. Starting state was the exact bug:
alsa_card.pci-0000_01_00.1 Active Profile: off
sinks: alsa_output.pci-0000_0d_00.6.analog-stereo
After the retry ran, both passes logged:
2 sink(s) present: alsa_output.pci-0000_01_00.1.hdmi-stereo,
alsa_output.pci-0000_0d_00.6.analog-stereo
default=alsa_output.pci-0000_01_00.1.hdmi-stereo
and the captured card profiles restored cleanly afterwards.
Two wiring details worth keeping:
- _activate_outputs_only() drops what _activate_all_outputs() returns. Only
the first call sees the user's real audio state; every retry captures the
alarm's own. Returning one would let a later caller restore the alarm's
settings as if they were the user's.
- _list_real_sinks_now() re-resolves pactl each pass and reports no sinks
when it is absent. The retry outlives the startup lookup and runs on a
thread nobody is watching, where an exception would vanish silently.
_alarm.py was exactly at the 250-line cap, so the flash timers move to
_alarm_flash.py as a mixin, mirroring AlarmTimersMixin. AlarmTimersMixin's
abstract _start_flash_countdown stub had to go: it is first in the MRO and
would shadow the real implementation. main() stays in _alarm.py --
morning_routine/_orchestrator.py:39 hardcodes that path.
conftest stubs the reactivation autouse; without it every WakeAlarm test
would reshape the host's audio routing.
409 tests pass, 100% branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
The PC shut down mid-session. This is the entry point for picking the work
back up without the transcript: what is done, what is left in what order, the
decisions already settled, and the traps that actually cost time.
Points at ~/.claude/plans/wake-alarm-still-does-witty-toucan.md for detail
rather than duplicating it, so the two cannot drift apart.
Every factual claim in it was re-checked against the tree, not written from
memory -- including the one that matters most: _reactivate.py is finished and
tested but is called by NOTHING (it appears only in a _startup.py docstring),
so item 6 is not actually fixed at runtime yet. That is step 1 for the next
session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Completes the fan removal started in 9e0cd12, which took the Python out but
left the shell side behind.
- Delete wake-alarm-fans.sh.
- install.sh step 7 now REMOVES the retired script instead of installing it.
An upgrade has to actively clean up: a machine that ran the old installer
keeps a passwordless-root sudoers entry for a script that no longer exists,
which is worse than the fan ramp ever was.
Verified on this machine rather than assumed -- both stale artifacts really
were present:
/usr/local/bin/wake-alarm-fans.sh (existed)
kuhy ALL=(root) NOPASSWD: /usr/local/bin/wake-alarm-fans.sh (granted)
After the cleanup: script gone, /run state gone, the fan NOPASSWD line gone,
`visudo -c` parses, `sudo -n true` still works, and the rtcwake NOPASSWD entry
the alarm actually needs is untouched. The sed was rehearsed against a fake
sudoers file first, since getting it wrong locks the user out of sudo.
- AGENTS.md: drop the stale "ramps fans to 100%" description, and point the
module map at the new DOCS-architecture.md (moved there to keep AGENTS.md
under the 250-line cap).
- DOCS-architecture.md: where each behaviour now lives after the split, the
HDMI-silence root cause, and the two timing invariants that must not
collide -- both of which silently gut the alarm if broken.
404 tests, 100% branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Item 14: the fan ramp is gone entirely. It caused anxiety without helping
anyone wake up. Deletes _max_fans/_restore_fans/_find_fan_hwmon, the
_AlarmHardware.fan_state field, both call sites, and their tests.
wake-alarm-fans.sh and its NOPASSWD sudoers entry come out in the install.sh
pass -- dropping a passwordless root helper is a bonus.
_alarm.py was 478 lines and the 250-line gate fires on any commit that stages
it, so this carries the split it has owed for a while. New modules, each a
real seam rather than an arbitrary cut:
_gating should-it-run-today + CLI (no time gate; that is _trigger's)
_beep_loop the sounding thread, now driving _escalation's planner
_alarm_timers self-rescheduling challenge refresh / skip window (mixin)
_alarm_state the progress + hardware dataclasses
_rounds challenge presentation, re-exporting the view helpers
_countdown skip-window arithmetic and demo/production intervals
_dismiss_view the post-dismissal banner
_screen_flash the ~1.3 Hz attention flash (kept under the 3 Hz threshold)
_lock_setup gatelock config + arbiter claim
_startup wake display, brightness, plug
_tone tone synthesis and cache (out of _audio)
_brightness xrandr brightness (was in _audio, never belonged there)
main() deliberately stays in _alarm.py: morning_routine/_orchestrator.py:39
hardcodes "wake_alarm._alarm", and moving it is the exact mistake that caused
a month-long silent production failure once already. Verified after the split
that the module still resolves and accepts --production.
Also fixes the plug pre-roll found while researching speakers: main() fired
turn_on_plug() and the first beep in the same instant, so an amplifier's
soft-start relay swallowed the opening of the alarm. PLUG_PREROLL_SECONDS=4
now sits between them.
Two bugs found by running the code rather than by lint:
- ruff stripped `struct` from the extracted _tone.py because the import
landed before its first usage; only executing it caught the NameError.
- test fixtures patched _play_on_all_sinks/_set_max_brightness at
wake_alarm._alarm, which silently stopped intercepting once those moved.
404 tests, 100% branch coverage, no new noqa/type-ignore suppressions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
The failsafe silences AUDIO ONLY at 300 s. The lock, the typed challenge and
the workout gate all persist, so waiting it out in bed is not an escape --
it only stops a ring nobody is present to dismiss from sounding forever.
Adding it exposed two collisions that would each have quietly defeated the
alarm:
1. The escalation ladder ended outside the audible window. PHASE_SOFT_END
was 5 minutes and PHASE_MEDIUM_END 15, so with the failsafe at 5 minutes
the alarm was silenced before it ever left its SOFTEST rung -- the medium
and loud phases became unreachable code. Printed timeline before the fix:
0s soft 440Hz ... 299s soft 440Hz | 300s failsafe-silent
Phases are now 1 and 2 minutes, so the alarm spends 3 of its 5 audible
minutes on the loud alternating rung, which is what makes lying in bed
for five minutes unattractive.
2. ESCAPE_OFFER_AFTER_SECONDS was also 300, so the escape hatch appeared in
the same instant the sound stopped -- at its most tempting, precisely when
the alarm had just stopped being annoying. Moved to 900.
Both invariants are now locked in by tests, so retuning one constant without
the other fails instead of silently degrading the alarm.
Also fixes a latent test bug this surfaced: TestBeepLoopPhases anchored
elapsed time to "one minute past the previous boundary", so a phase retune
made it miss its rung, never set _stop_beep, and spin forever calling the
REAL audio stack -- hanging the whole suite rather than failing. It now uses
the midpoint of the band and patches every rung, so a mistuned boundary fails
fast.
test_alarm_part3.py split (372 -> 195) for the 250-line gate.
413 tests, 100% branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Root cause of the silent-alarm-on-HDMI bug, measured on the host rather than
inferred:
$ pactl list cards | grep 'alsa_card.pci-0000_01_00.1' -A1
Active Profile: off
$ pactl list short sinks
61 alsa_output.pci-0000_0d_00.6.analog-stereo IDLE
While that card's profile is "off" the G27Q's HDMI sink does not exist, so
_list_real_sinks() cannot return it. _play_on_all_sinks re-enumerates sinks
every beep cycle -- its docstring says it picks up late outputs "without
re-running activation" -- but that can never find a sink whose card is off.
There is nothing to enumerate.
Activation runs exactly once, at startup. At wake time the monitor has not
finished its HDMI handshake, the profile reads "available: no",
_parse_profile_line drops it, and the card is never revisited. Silent alarm.
_reactivate re-runs activation on a decaying schedule (3,5,7,10,15,20,30s)
on a daemon thread, logging the full enumerated sink list every pass so the
next failure is diagnosable from the JSONL log instead of guessed at.
Verified end to end on this machine:
BEFORE: ['alsa_output.pci-0000_0d_00.6.analog-stereo']
AFTER : ['alsa_output.pci-0000_01_00.1.hdmi-stereo',
'alsa_output.pci-0000_0d_00.6.analog-stereo']
and a 1 kHz tone plays through the HDMI sink (paplay exit 0). Note the
constant ALARM_AUDIO_SINK was correct all along; only the activation was
failing.
Wiring into WakeAlarm lands with the _alarm.py split, since touching that
478-line file trips the 250-line gate.
399 tests, 100% branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Adds the diagnostic substrate the rest of the hardening work builds on. A
6am failure should be reconstructable afterwards instead of re-debugged.
Two sinks, deliberately. stderr -> journal is unchanged. Alongside it, one
JSON object per line in ~/.local/state/wake_alarm/events.jsonl, so a morning
can be replayed with jq.
The hard constraint is that _trigger is a fresh process every 60 seconds, so
an in-memory rate limiter would reset on every tick and suppress nothing.
_log_dedupe therefore persists the last emitted fingerprint to disk and
flushes a suppressed_repeats count onto the next distinct record. Verified
across separate processes: 4 invocations logging the same routine event
produce 1 record plus an accurate count of 3.
That is what satisfies the standing "do not raise the level of _sync's
degraded-read paths" rule by construction, rather than by keeping levels
artificially low and losing the signal.
The correlation id is derived, never generated: both halves compute
wake-YYYYMMDD-HHMM from the resolved alarm instant, so the PC and the phone
agree with no handshake and no uuid exchange -- which matters precisely when
sync is down.
Logging never raises. An unwritable sink degrades to journal-only.
_alarm.py and _sync.py keep the default component= for now; tagging them
means splitting them for the 250-line gate, which belongs in the phases that
actually restructure those files.
389 tests pass, 100% branch coverage held.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
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
The rename commit staged the git mv but not the accompanying content
edit, so these TODO files landed without the "REMOVE ME AFTER FINISH"
line the md-naming gate requires. Repos whose hook was already live
refused that commit; repos without it accepted a file the gate would
now reject.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
Renames this repo's markdown onto README.md / CLAUDE*.md / DOCS*.md /
TODO*.md so a future session can tell a live task from a finished
record without reading the file to find out.
TODO files carry the literal "REMOVE ME AFTER FINISH" and are deleted
when the work lands. Enforced by the md-naming pre-commit hook and CI
workflow, both delegating to the shared gate in kuhyx/utils so this
repo's idea of a valid name cannot drift from every other repo's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7