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
13 KiB
Wake-alarm hardening — continue here
REMOVE ME AFTER FINISH
Phases 0–7 are committed (4286fcb..f5eb7fc). This file is the entry point
for the rest. Full plan, with every measured finding:
~/.claude/plans/wake-alarm-still-does-witty-toucan.md — read it before
starting; this file deliberately does not duplicate it.
Done so far
| Item | State |
|---|---|
| 6 audio (HDMI silent) | Done. _reactivate wired into WakeAlarm; verified on the host — the HDMI sink appeared and became default. |
| 7 speakers | DOCS-speaker-recommendation.md. Prose only, done. |
| 9 refactor | _alarm.py 478→250. 12 new modules; see DOCS-architecture.md. |
| 11 failsafe | FAILSAFE_SILENCE_SECONDS=300, audio only. Escalation re-tuned to fit inside it. |
| 12 logging | JSONL + cross-process dedupe + derived correlation id. |
| 14 fans | Gone: code, script, sudoers entry, installed state. Three stale doc/message references cleaned up in phase 6. |
| 2 power button | Done. _inhibit.py (logind block, self-terminating, stale cleanup by pid via /proc) + _alarm_session.py fire-time record, re-fired by _trigger outside the catch-up window. |
| 10 vmbox | Done. scripts/vmbox/harness.sh + DOCS-vmbox.md. 8 cases, fails closed. Found 2 live bugs the host could not reach. |
| 5 curtains | Done. _curtains.py driver protocol + no-op default, called from prepare_hardware(). DOCS-curtains-hardware.md has the buy list. |
| 1/4 phone alarm | Done, verified on the phone. Five Kotlin files behind one MethodChannel + the Dart ring screen. Fires from Doze on time, wakes the screen over the lockscreen, rings through DND on STREAM_ALARM, and dismisses via an NFC tap + challenge. Confirmed by the user on the Pixel 6a, not inferred. |
| 3 room proofs | Mostly done. NFC works end to end: NfcReader.kt (hand-written), Settings → "Room proof" registers the tag, and a tap satisfies the gate. Steps+elapsed is the fallback. QR is specified, persisted and unit-tested but has no scanner UI — mobile_scanner is the droppable extra. |
Remaining, in order
QR scanner UI— cancelled 2026-08-30. QR was deleted rather than finished: it had never been reachable at ring time (no scanner package, no CAMERA permission,onQrScannedhad no caller), and a printed code is photographable, so completing it would have added a bypass to the very gate the same change was hardening. The dismiss path is now a strict sequence: tag 1 → 25 steps + 2 min → tag 2 → 3 challenges.- Item 8, mutual gating. Phone half is now in place. This is the phase
that legitimately touches
_sync.py, so it carries that split (see below). - Item 13, black screen. Different repo (
~/testsAndMisc/ linux_configuration), host-only, blocks nothing.
Verified end to end on the Pixel 6a, 2026-08-27
The whole path is confirmed on hardware, by the user, not inferred:
- Fires from Doze on time (22:03:01 for a 22:03 arm), registered by the system
as
RTC_WAKEUP/ "Next wake from idle". - Wakes the screen (
Asleep→Awake) and putsAlarmActivityon top over the lockscreen, with the full-screen-intent notification posted. - Rings through DND —
zen_mode=1, playerstate:startedonusage=USAGE_ALARM/STREAM_ALARM(4),mutedState:none. The STREAM_ALARM claim is measured, no longer assumed. - NFC tag registers (Settings → Room proof) and a tap dismisses:
tag discovered: 0465dab3ce2a81on the ring screen, challenge solved, alarm silent. - Step counter reaches the gate.
Four defects were found only because it was run on the phone, every one invisible to the emulator (no NFC, no step sensor) and to the widget tests (which mock the channel):
AlarmActivity.onPausecalledNfcReader.stop().enableReaderModeis per-activity butdisableReaderModeis global, so anything briefly in front of the ring screen killed polling for the rest of the ring.TYPE_STEP_COUNTERis cumulative and on-change: it emits nothing until the user has already walked, and the first emission was being eaten as the baseline, so the screen sat at0/25forever.TYPE_STEP_DETECTORis now primary — one event per stride, no baseline to miss.- NFC does not poll while the ring shows over a locked keyguard
(
mScreenState=ON_LOCKED).requestDismissKeyguard()is called on fire. It does not silently unlock a secured phone — it raises the PIN prompt — so "tap without touching the phone" was never achievable on a secured device. Say that plainly; don't let docs imply otherwise. - The flash challenge hides its code after 4s, which is unsolvable when the screen is reached by walking to another room. Removed from the phone's rotation, with a test asserting no self-hiding challenge is ever shown.
A process lesson worth more than any of them: after hardware confirmed the path worked, the next change bundled a MethodChannel restructure with a bug fix and regressed both NFC and steps. The user caught it. When hardware has just gone green, make the smallest possible delta on top of that exact state — never a restructure and a fix together.
Fully automatic, verified on the Pixel 2026-08-27
The alarm is no longer opt-in and can no longer be weakened:
- Arms itself with zero taps. Launching the app was enough —
dumpsys alarmshowedRTC_WAKEUP ... com.kuhy.wake_alarm_sync/.AlarmReceiverat2026-08-28 08:00:00,window=0,exactAllowReason=policy_permission. - 08:00 daily is baked in (
AlarmPolicy.baselineHour, mirrored by the PC'sFALLBACK_ALARM_HOUR). There is no arm button and no cancel — the Kotlincancelhandler is deleted, so the capability does not exist rather than being oneinvokeMethodaway. - Earlier-only ratchet, confirmed on-device. Picking a later time was rejected on the phone with "That is not earlier than 08:00. The alarm can only be moved earlier.", and the armed alarm stayed at 08:00. An earlier start is one-shot and resets after the alarm fires.
- Daily watchdog (
AlarmWatchdog) re-arms if nothing is scheduled, andBootReceiverre-establishes it becausesetInexactRepeatingdoes not survive a reboot. - PC watchdog:
_trigger._clamp_to_baseline()refuses any resolved alarm later than 08:00 whatever its source, so a stale or tampered remote value cannot move the alarm later through sync.
DND: verified for priority mode, NOT for total silence
Measured on the Pixel: STREAM_ALARM is unmuted under priority DND
(zen_mode=1, mask 0x1a6) and muted under total silence
(zen_mode=2, mask 0x9be). The confirmed ring was priority DND. No audio
flag escapes total silence, so if that gap matters the fix is a zen-policy
check at arm time, not an audio change. Detail in DOCS-phone-alarm.md.
Two harness traps found while testing this, worth keeping:
- Do not pass
--build-numberto a probe build — it produced a non-running APK. - The arm probe can register for TOMORROW. It calls
schedule(), thenmain.dart'srearm()recomputesnextOccurrence; if the armed minute has passed by then the alarm lands on the next day. Always re-check the registeredtriggerTimeis today before waiting for a ring.
Still unverified
- The two-tag sequence has not been walked end to end on the phone: only one physical tag is registered, so every on-device run so far has fallen through to the 5-minute bypass. Needs a second tag.
- The locked-keyguard dismiss was not run to completion: the confirmed run was on an unlocked phone. The PIN-prompt path is implemented but unproven.
Decisions already made — do not re-litigate
- Failsafe stops sound only; lock, challenge and workout gate persist.
- Gating is phone first, then PC, with a bounded escape: if sync is unreachable for ~600 s the PC gate degrades to locally-solvable, logged WARNING. Without that, a Firebase outage means you can neither dismiss nor get to work.
- Room proof: NFC or QR qualifies. Steps alone does not — the phone is stationary in the other room while you walk to it, so steps only accrue after pickup and can be faked by shaking it in bed. Steps + elapsed time is the fallback when both others fail. (I narrowed "any one satisfies" here; say if you disagree.)
- Phone alarm: hand-written Kotlin behind one MethodChannel, not a plugin.
AGP 9.0.1 / Gradle 9.1 / Kotlin 2.3.20 is bleeding edge and a plugin that
won't compile breaks the build outright.
STREAM_ALARMis load-bearing — it is what ignores DND and silent mode. - Curtains: no hardware; pluggable driver + logging no-op default.
- Quality bar: 100% branch coverage, no new
# noqa/type: ignore.
Traps found in the phone session
- The plan's
LogStore.changesdoes not exist.crdt_sync_darthas no Stream at all. Re-arm therefore needs three explicit hooks — startup, BOOT_COMPLETED, dismiss — and dropping any one makes the alarm ring once and then silently stop forever. - The Android SDK was never missing, only unexported.
~/Android/Sdkhascmdline-tools,emulatorandplatform-tools; onlyadbwas on PATH. ExportANDROID_HOMErather than installing anything. - A permission declared in the manifest is not a permission granted.
Both
POST_NOTIFICATIONSandACTIVITY_RECOGNITIONare runtime grants, and each one missing silently breaks a different half of the dismiss path (no full-screen intent / no step sensor). The AVD caught both because a freshly installed app there sits atimportance=NONE. - A debug probe entrypoint with its own UI proves nothing about the route.
The first probe rendered its own widget, so the AVD run validated the Kotlin
and nothing about
/ring. Point the probe at the real app widget.
Traps that cost time this session
- The 250-line gate fires on staged files. Touching a file means
splitting it in the same commit. Still over (re-measured 2026-08-27, after
phase 6):
test_sync.py386,_sync.py371,test_smart_plug.py351,_sinks.py341,_alarm_view.py329,_escape_flow.py317,test_state.py314. Budget for it.test_trigger.pyis now split and under. morning_routine/_orchestrator.py:39hardcodes"wake_alarm._alarm"(different repo,~/testsAndMisc). Any module-path change updates it in the same commit. This exact omission caused a month-long silent outage. Keepmain()in_alarm.py.- Splitting a test file loses its fixtures.
_make_mock_tk,_block_real_tkand_block_extra_deviceslive after the imports, so a naive header-copy drops them and every test errors. Copy the whole region before the firstclass. Also: a multi-line module docstring gets stranded if you replace only its first line. - Moving a symbol breaks
patch("wake_alarm._alarm.X")silently — the patch stops intercepting and the test asserts nothing. Grep the wholetests/tree for the old target after any move. - Autoformat strips an import added before its first usage.
structvanished from the extracted_tone.py; ruff, mypy and pylint all passed. Only running it caught theNameError. Add import and usage in one edit. - Never let a beep-loop test miss its rung. A test that patches only one
of
_beep_soft/_medium/_loudand waits for it will spin forever on the real audio stack and hang the whole suite instead of failing. Patch all three. - Run tests with
/usr/bin/python3, not the dev venv (nogatelockthere). vm rundoes not propagate the guest command's exit code — it reports the VM's verdict. A gate built on it fails open. SeeDOCS-vmbox.md._state.py's UTC_today_str()must NOT be flipped to local, despite what the plan says.screen_locker/_wake_state.pymirrors the helper and reads the samewake_state.json; flipping it loses an earned workout skip for any dismissal between 00:00 and 02:00 local._alarm_session.pyshows the way around it: carry an awarefired_at, compare elapsed time, no date key at all.- Do not split a file the current change does not already have to touch.
The gate fires on staged files, so an oversized file only becomes your
problem once you edit it. Editing
_sync.pymerely to repoint an import pulled its 371 lines into the changeset and cascaded into five new modules and a broken tree before being reverted. Prefer putting a new constant in its single consumer (as_curtains.pydoes) over touching_constants.py. - A
__main__CLI extracted to a new module breaks its documented command silently.python -m wake_alarm._sync --showprinted nothing and exited 0 — invisible to ruff, mypy, pylint and the suite alike. If a later phase moves that CLI, run the documented command and check the output. - Ruff's autofix will gut a function whose results are only printed —
it stripped both the assignments and the
printfrom a vmbox check, and every linter stayed green. Grep the file for its output call after any autofix.
Verify
/usr/bin/python3 -m pytest wake_alarm/tests/ -q # 455 pass, 100% cov
bash scripts/vmbox/harness.sh # 8/8 cases, exit 0
pre-commit run --files <changed>
/usr/bin/python3 -m wake_alarm._trigger --dry-run
/usr/bin/python3 -m wake_alarm._alarm --demo # real window; ask first
Done = every item above shipped, this file deleted.