Interactive walkthrough of the move from hibernate/rtcwake scheduling to the
per-minute trigger timer: why the old model broke when the PC stopped being
shut down, the login-fire hole that came with it, the fire window and its
catch-up, and the four-step alarm resolution chain.
Includes a simulator that runs the real _decide() rule, so the window and the
gate short-circuit can be poked at rather than just read about.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The doc claimed the migration pinned crdt_sync_dart-v0.3.0. It never did:
phone_app/pubspec.yaml has said v0.2.0 since the migration commit itself
(512fdd5), and pubspec.lock resolves it to 5be99ab. MIGRATION.md was written
afterwards (21aa3bd) and got the number wrong, so the doc drifted, not the
code — the build has always been v0.2.0.
A v0.3.0 tag does exist upstream. Say so explicitly, and say that not using it
is an untaken decision rather than a mismatch to reconcile, so the next reader
doesn't "fix" the pubspec to match a doc that was wrong.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adding the Flutter phone_app/ directory gave setuptools two top-level
candidates in a flat layout, so it refused to build:
error: Multiple top-level packages discovered in a flat-layout:
['phone_app', 'wake_alarm']
install.sh runs under `set -euo pipefail` and does the editable install
first, so every run since has aborted at step 1 — silently skipping the
systemd units, the sleep hook, the fan script and the shutdown wrapper.
That is why /usr/local/bin/shutdown was still the pre-token-fallback
version: not a forgotten reinstall, a broken one.
Declare the build backend explicitly and restrict discovery to
wake_alarm*, so phone_app is ignored. All 9 install steps now run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Adopt the shared icon family (python_pkg/app_icons in testsAndMisc): a
charcoal #1B1D21 field carrying an orange clock glyph, orange being this
app's own theme seedColor.
The clock is deliberately drawn without bell "ears" - at launcher size they
read as noise rather than as an alarm clock.
Adds the adaptive-icon layers the project never had, including an Android 13
monochrome layer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
phone_app: the alarm now syncs through the shared crdt_sync transport
(v0.2.0) into the fixed private repo kuhyx/syncs, replacing the bespoke
Gist. AlarmSyncService is the sole writer: it holds the alarm locally in a
crdt_sync LogStore (one HLC-stamped Record) and pushes it to
wake-alarm-sync/devices/phone/alarm.json. gist_service.dart is deleted;
SyncSettings drops the Gist id and keeps only a token; the device-flow scope
moves from `gist` to `repo` (the Contents API needs private-repo access).
shutdown-wrapper.sh: the PC reader now fetches that alarm from the Contents
API (Accept: application/vnd.github.raw, so no base64) and parses the
crdt_sync Record Log with jq (.alarm.fields.alarm_hour[0]). Single-file read
of the sole "phone" device; unchanged fallback to WAKE_AFTER_HOURS on any
miss so a parse failure can never brick the wake. Config moves from
gist_sync.json {token, gist_id} to sync.json {token}.
Verified: flutter analyze clean + tests pass; shellcheck clean and the full
get_synced_alarm_epoch verified against the real Log format; on-device the
app builds, installs with adb install -r (no data loss), launches and
renders. Live round-trip needs a one-time repo-scoped re-auth on the phone
and ~/.config/wake_alarm/sync.json on the PC.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
Two spots blocked the alarm on a timer, guessing when the monitor's HDMI
sink would be "ready":
- _activate_all_outputs polled _list_real_sinks in a loop with
time.sleep, waiting up to 20s for ALARM_AUDIO_SINK to appear.
- main() slept DISPLAY_WAKE_WAIT_SECONDS (5s) after waking the display
before audio setup.
Both are gone. Activation now forces every card's output profile on and
activates whatever sink is present at that instant; the beep loop's
_play_on_all_sinks already re-lists real sinks every cycle, so a slow
monitor is swept in the moment it actually enumerates — a real presence
check, never a timed guess. main() wakes the display and proceeds
immediately; the monitor powers on in parallel.
Removes now-dead constants ALARM_AUDIO_CARD, ALARM_AUDIO_PROFILE,
ALARM_AUDIO_SINK_WAIT_SECONDS, ALARM_AUDIO_SINK_POLL_SECONDS,
DISPLAY_WAKE_WAIT_SECONDS. Activation returns in ~0.26s (was up to 20s);
the test suite drops from ~12s to ~2s (two main() tests were each really
sleeping 5s). 100% branch coverage retained.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SJ6HYXhBmYs3SiGBZv1rF
At wake time PipeWire often has only the auto_null sink (verified from
journalctl on 2026-07-11: "ONLY auto_null PipeWire sink available — alarm
will be SILENT"). The old code forced only the single HDMI monitor card
on and waited for its one sink; when that never enumerated, all audio
drained into auto_null = silence.
Now activation forces *every* card's output profile on, unmutes and maxes
every sink, and the beep loop plays on *all* sinks each cycle
(paplay --device), re-enumerating so late/added outputs are covered. A
switched-off external speaker can't be revived by software, so every
other output is tried in parallel instead of betting on one. Prior
per-card profile and per-sink volume/mute are captured and restored on
dismiss.
Concurrent paplay children are now reaped each cycle (was 1/cycle, would
have been N/cycle of <defunct> over the 30-min loud phase).
Extract the pactl sink/card management into wake_alarm/_sinks.py to keep
_audio.py under the 500-line limit. 100% branch coverage retained.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SJ6HYXhBmYs3SiGBZv1rF
Mirrors the CI-green safeguards rolled out repo-wide (see screen-locker):
- scripts/check_requirements_sync.py: pre-commit hook that fails if a
package import isn't declared in requirements.txt. Added a kasa ->
python-kasa entry to the import->distribution map (python-kasa ships the
`kasa` import, already declared correctly in requirements.txt).
- scripts/ci_mirror.sh: pre-push gate = requirements.txt-only venv
(hash-gated) running `pre-commit run --all-files` + `pytest`, replacing
the plain pytest-coverage pre-push hook.
Verified: mirror green (210 passed). Only the tooling files are committed;
unrelated in-flight work in the tree is left untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PMRRVVkeRyrSaaqueMZKG
New Flutter app (com.kuhy.wake_alarm_sync) that sets the phone alarm and
writes the wake time to a GitHub Gist for cross-device sync: Gist service,
GitHub device-flow auth, sync settings, home/settings screens. Source only;
build/.dart_tool/.idea artifacts excluded by the app's .gitignore.
Not yet gated by CI (wake-alarm CI is Python-only) and pending on-device
verification.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
- _constants.py: ALARM_DAYS now every day (was Mon/Fri/Sat/Sun);
DISMISS_CODE_LENGTH 8->10 and DISMISS_ROUNDS_REQUIRED 2->4 so
dismissing forces staying awake longer.
- _challenges.py / _alarm.py: expanded dismissal challenge logic, with
matching test updates.
- shutdown-wrapper.sh: updated hibernate/rtcwake wrapper.
- .gitignore: restore the missing rule for wake_alarm/wake_state.json
(HMAC dismissal runtime state — must never be tracked, per CLAUDE.md).
Tests: 191 passed, 100% coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
gatelock was folded into github.com/kuhyx/utils as a subdirectory;
update the pip git URL across requirements.txt, pyproject.toml, and
.pre-commit-config.yaml to the new location with the renamed tag.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A one-off `pip install "wake_alarm @ git+..."` from a scratch
directory was used during the original cutover; that's a non-editable
snapshot that a later git push silently never reaches. Document the
correct deployment convention (install.sh from a durable ~/wake-alarm
clone) so this doesn't drift again.
Rewrites python_pkg.wake_alarm imports to wake_alarm, vendors the
shared configure_logging helper, drops the monorepo PYTHONPATH from
install.sh and the systemd unit (package is now pip-installed), and
untracks wake_state.json (runtime HMAC state, now gitignored). Scaffolds
standalone lint/test config copied from the already-corrected diet_guard
scaffold (pylint --fail-under=10 with tests excluded and the
use-implicit-booleaness/consider-using-with disables, mypy's actual
disabled-error-code set, ruff ALL, bandit, 100% branch coverage), plus
the wave.Wave_write generated-members fix this package's _audio.py needs.
The previous two fix attempts (removing -n 4 xdist parallelism, ruling
out a Python 3.12-vs-3.14 difference) both targeted the wrong cause for
the coverage gap on _alarm_display.py:71. The actual bug:
_restore_display() was never mocked in the _block_extra_devices fixture
shared by test_alarm_part2/3/4.py, so every test exercising
on_close()/_lock.close() ran the REAL _restore_display(), which calls
the REAL shutil.which('xset'). My dev machine has /usr/bin/xset
installed, so it always hit the "found" branch by accident; the CI
runner doesn't, so it always hit the "missing" branch instead, and
there was no dedicated unit test for the "found" path at all.
Fix: mock _restore_display in that fixture (matching its already-mocked
siblings _restore_fans/_restore_alarm_audio/turn_off_plug), and add a
dedicated, hermetic test for the xset-found branch that doesn't depend
on whether the running machine actually has xset on PATH. Side benefit:
the test suite no longer makes a real `xset s on` subprocess call on
the developer's desktop as an untracked side effect of running tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A7vbgtFfZmfxJtN5DdtJky
Third and final leg of the diet_guard -> screen-locker -> wake_alarm
gatelock extraction. WakeAlarm now composes gatelock.GateRoot +
LockWindow(mode="soft") instead of driving tk.Tk() directly, and moves
hardware teardown into on_close() so it runs on every exit path
(including SIGTERM), closing the prior gap where killing the process
left fans maxed. _state.py's HMAC import moves to gatelock.log_integrity,
the last call site of python_pkg.shared.log_integrity, which is deleted.
Manually verified on the real display: fullscreen overlay renders,
xdotool-typed input reaches the dismiss-code Entry (mode="soft" keeps
the existing typing-focus behavior), and SIGTERM exits within ~0.4s
while restoring hardware state. Full repo suite: 949 passed, 100%
branch coverage.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A7vbgtFfZmfxJtN5DdtJky
Split diet_guard/_gatelock.py, wake_alarm/_alarm.py, and the
usage_report.py/_usage_report_parsing.py pair into focused
sub-modules so every Python file is <= 500 lines, satisfying
test_file_length.py. Install python-kasa into .venv (declared in
requirements but missing after the 3.13->3.14 venv upgrade),
fixing 8 failing smart_plug tests and restoring 100% coverage.
Also includes prior in-progress work from the working tree: the
wake_alarm Progress/View/Hardware field-grouping refactor,
brother_printer query module + tests, diet_guard foodbank/state/cli
updates, new shared coerce/logging_setup helpers, morning_routine
orchestrator tweaks, dwm window-manager config, gaming scripts, and
misc maintenance/digital-wellbeing script updates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Split _alarm.py (1059 lines) into _alarm.py + _audio.py + _challenges.py
- Split test files (1305 / 725 lines) into 6 files, all under 500 lines
- Replace random.* with secrets.* (S311); fix RUF001, SIM117, E501 ruff errors
- Rewrite pytest_changed_packages.py to always run all packages with global
--cov python_pkg coverage (100% branch coverage enforced across whole tree)
- Add DISMISS_ROUNDS_REQUIRED=2 and DISMISS_FLASH_SECONDS=4 to _constants.py
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New python_pkg/morning_routine package: sequential orchestrator runs wake
alarm then workout lock as blocking subprocesses (one fullscreen owner at
a time). Deployed as morning-routine.service; sleep hook updated to start
it on hibernate-resume instead of the standalone wake-alarm.service.
- wake_alarm: force G27Q HDMI card profile on at alarm time, poll up to 6s
for sink to appear, set as default + unmute 100%. Alarm now persists until
the typeable code is entered (no more silent 30-min give-up). Service gets
DISPLAY=:0 + ExecStartPre sleep 1 to fix cold-boot Tkinter crash.
- phone_focus_mode/config.sh: whitelist Revolut, mObywatel, VaultKitBypass.
- 100% branch coverage maintained across wake_alarm and morning_routine.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Drop overrideredirect on the dismiss UI: on X11 it bypassed the WM and
silently killed keyboard focus on the Entry, so the user could not type
the dismiss code. -fullscreen + -topmost still cover the whole screen.
- Add _max_sink_volume() / _restore_sink_volume(): unmute the default
PulseAudio/PipeWire sink and raise it to 100% at alarm start, restore
the original volume + mute state on dismiss. This is the biggest audio
lever — pcspkr is hardware-fixed and was already maxed.
- pcspkr: 3 back-to-back 1.5s beeps per cycle (was 1x 0.8s) at near-S16
max amplitude.
- Fans script: control every NCT pwm[1-9] channel, persist per-channel
pre-alarm state to /run/wake-alarm-fans.state so restore needs no args.
- Tests: 100% branch coverage on python_pkg/wake_alarm/ (140 passed).
- Add python-kasa-based smart-plug control (_smart_plug.py) with
turn_on_plug / turn_off_plug called around the alarm window.
Reads ~/.config/wake_alarm/tapo.json (host/email/password).
- Hard timeout (TAPO_TIMEOUT_SECONDS) so plug never blocks the alarm.
- Install fan-control script + sudoers entry (install.sh step 6);
_max_fans / _restore_fans now invoke it via /usr/bin/sudo -n so
pwm1_enable writes succeed.
- Remove ntfy.sh push notifications entirely (silent no-op was useless).
- Replace every silent skip with _logger.warning() so failures are
loud: missing xset / xrandr / speaker-test, unreadable hwmon files,
fan script errors, missing Tapo config, kasa import failure, etc.
- wake-alarm.service: Restart=on-failure with 10s backoff.
- Tests: 100% line+branch coverage on python_pkg/wake_alarm.
- install.sh: install sleep-hook.sh to systemd-sleep hooks (step 3) and
shutdown-wrapper.sh to /usr/local/bin/shutdown (step 5)
- shutdown-wrapper.sh: new script that intercepts shutdown calls and
redirects to rtcwake -m disk on alarm nights (Mon/Fri/Sat/Sun), pass-
through for reboots and cancel commands
- sleep-hook.sh: new systemd-sleep hook that restarts wake-alarm.service
for each logged-in user after hibernate resume
- setup_midnight_shutdown.sh: check wake-alarm day; if yes set RTC alarm
and hibernate, otherwise fall back to systemctl poweroff