diff --git a/AGENTS.md b/AGENTS.md index 79274cb..d869297 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,10 +7,14 @@ dismissal; this repo's Python half only reads the alarm time the phone published and starts the morning routine at that time. The time is an earned ramp, 08:00 down to 07:00, owned by the phone alone — **`DOCS-ramp.md`**. -The PC's waking role was deleted on 2026-08-30: no alarm window, no audio -escalation, no smart plug, no curtains, no `rtcwake`, no `shutdown` wrapper, -no sleep hook: waking someone reliably cost more than it returned. It is in -git history; do not reintroduce any of it without saying why the phone can't. +The PC's waking role was deleted on 2026-08-30: no alarm window, no smart +plug, no curtains, no `shutdown` wrapper, no sleep hook. On 2026-10-01 exactly +two pieces came back, because the phone **cannot** ring while powered off: a +**fallback ring** when the phone is silent or no session started, and an +**RTC self-wake** before Tue/Wed/Thu. Plus the stacking phone lockdown and a +temporary escape hatch (until 2026-10-15). All of it, and the data contract +the phone, PC and focus-owner share: **`DOCS-wake-enforcement.md`**. Anything +else from the old PC alarm still needs a reason the phone can't do it. ## The dismiss sequence (phone) @@ -183,22 +187,17 @@ diet_guard outage. Verify against `/usr/bin/python3 -c "import "`. ## Uninstalling -`uninstall.sh`, never `rm -rf` on the repo. `/usr/local/bin/shutdown` is -root-owned and shadows `/usr/bin/shutdown`; deleting the repo leaves it in -place, and with the alarm cache gone it falls through to -`sudo rtcwake … ; exec systemctl hibernate` — so `shutdown now` would silently -stop being a poweroff, every day, forever. - -`install.sh` calls `uninstall.sh --retired-only` as its last step, so +`uninstall.sh`, never `rm -rf` on the repo: it also removes the root ring +unit, the logind power-key drop-in and an RTC alarm we armed, and retires the +old `/usr/local/bin/shutdown` wrapper wherever one survives. `install.sh` calls `uninstall.sh --retired-only` as its last step, so **`uninstall.sh` is load-bearing for installing**, not just for removing — renaming or deleting it breaks a fresh install. That call is non-fatal and gated on `sudo -n`: the cleanup needs root, and a hard failure there would abort an install that had already placed the package and enabled the timer. **`/etc/sudoers.d/wake-alarm` is deliberately left in place** even by a full -uninstall. Its last known consumer, screen-locker's `_schedule_rtcwake`, was -dead code and deleted on 2026-09-27; retiring the entry is a separate, -deliberate decision, never a side effect of an uninstall. +uninstall. Since 2026-10-01 it is live again: the RTC self-wake's `rtcwake` +and the root ring unit's `systemctl start|stop` lines live there. ## Operational gotchas diff --git a/DOCS-wake-enforcement.md b/DOCS-wake-enforcement.md new file mode 100644 index 0000000..dc87d01 --- /dev/null +++ b/DOCS-wake-enforcement.md @@ -0,0 +1,146 @@ +# Wake enforcement v2 — PC fallback alarm, immediate stick, escape hatch + +Agreed with kuhy on 2026-10-01. This file is the **contract** between the +phone app, the PC package, focus-owner (`~/src/phone-focus-mode`) and the +daily audit. Change a name here and in every reader in the same commit. + +## Why + +The carrot (`DOCS-morning-session-pc.md`) pulls only on days the PC is used +in the morning — Fri–Mon. On Tue/Wed/Thu kuhy leaves for work, so the only +lever is something that bites *today*. Two holes made skipping cheap: + +1. **A phone switched off before the alarm escaped everything.** + `RingWatch.markFired` never ran, so `WorkdayStick` saw no abandoned ring. +2. **Nothing rang if the phone was off.** The PC's waking role was deleted on + 2026-08-30; this brings back exactly one piece of it because the phone + *cannot* ring while powered off — the reason AGENTS.md asks for. + +## Behaviour + +### PC fallback alarm (every ringing day) + +Let `A` be today's alarm (`_sync.get_alarm()`, clamped ≤ 08:00 like the +trigger). Not on a free day. Per-minute tick, `A-5min .. A+45min`: + +| condition (first match wins) | action | +|---|---| +| escape recorded for today, or a session started today | stop / do not ring | +| ring already ran 30 min today (`FALLBACK_RING_CAP`) | never again today | +| `now ≥ A + 2 min` and no `ring_started` for today | ring — `phone_silent` | +| `now ≥ A + 15 min` (`SESSION_NONE_GRACE`) | ring — `no_session` | + +A ring stops on: session start, escape, or the 30-min cap. Firebase +unreachable counts as "no ping, no session" (fail toward ringing) and is +logged as `firebase_unreachable`. While ringing, stop conditions are polled +every 20 s. + +**Sound.** 100 %, unmuted, a tone that rises over the first 60 s. +- Active graphical session on seat0 → user PipeWire (`wpctl` + `pw-play`). +- Otherwise (night lockdown: lightdm masked, nobody logged in; kuhy is not in + `audio`) → root system unit `wake-alarm-ring-alsa.service` playing straight + to the speakers' card (`ALC897 Analog`, card name `Generic_1`) via `aplay`. + +### PC wakes itself (nights before Tue/Wed/Thu) + +`wake-alarm-rtc.timer` (evenings + on boot) arms the RTC for +`A − 10 min` of the next stick-day morning with +`sudo -n /usr/sbin/rtcwake -m no -t ` (already NOPASSWD). The alarm +lives in CMOS, so it survives however the PC goes down. + +`~/.config/wake_alarm/pc_wake.json` `{"mode": "hibernate" | "poweroff"}`: +- `hibernate` (default): a logind drop-in makes the **power key** hibernate. + Verified on the host 2026-10-01: two `rtcwake -m disk` cycles resumed + 29–44 s after the alarm (FADT: "RTC can wake system from S4 = 1"). + A typed `shutdown` stays a real poweroff. +- `poweroff`: no logind change; relies on the firmware waking from S5. + Unverified until the host S5 test runs. + +### Phone stick (Tue/Wed/Thu, not free days) + +A **miss** = no dismiss by `A + 15 min` and no escape for today. Detected by +an exact alarm at `A + 15` *and* at boot (stored alarm `A` passed by ≥ 15 min, +not dismissed today) — the boot path is what closes hole 1. + +Lockdown length stacks over **consecutive stick-day misses** (free days are +skipped, they neither break nor extend the streak; a dismissed or escaped +stick day resets it to 0): + +| streak after this miss | locked through | +|---|---| +| 1 | today + 1 day | +| 2 | today + 2 days | +| n | today + min(n, 4) days (5 calendar days max) | + +During a workday lockdown focus-owner applies its **lockdown allowlist** +(calls, SMS, Signal, maps, transit/ticket, banking + authenticator, +wake-alarm, todo) — narrower than the night list. + +### Escape hatch (until 2026-10-15 inclusive) + +Home screen button "That was a bug". Needs a typed reason ≥ 40 characters. +It lifts the phone lockdown, clears the streak, cancels today's PC penalty, +stops a ringing PC alarm, and writes everything into the incident report. +Unlimited uses — every use is listed at the top of that day's report. +After `escapeHatchLastDay` the button is gone (constant, not a setting). + +### Stakes shown in the app + +Home card: today's stakes (stick day or carrot day), the carrot cutoff, any +pending lockdown/penalty and the streak. Ring screen: one line. Every number +shown comes from Dart constants mirrored against the Python ones by +`wake_alarm/tests/test_stakes_mirrors.py`. + +## Data contract + +### Firebase — fields on the phone's `alarm.json` (`stageField`) + +| field | value | +|---|---| +| `ring_started` | `{"date": "YYYY-MM-DD", "at": ""}` — set when the ring screen opens (Dart). | +| `escapes` | list, newest first, ≤ 30: `{"id", "date", "at", "reason"}` — `date` is the morning being excused. | +| `stick_state` | `{"streak": n, "locked_through": "YYYY-MM-DD" \| null, "last_miss": "YYYY-MM-DD" \| null}` | +| `phone_events` | list, newest first, ≤ 400 entries and ≤ 14 days: `{"at", "kind", "data": {…}}` | + +### focus-owner broadcasts (signature permission `SIGNAL_LOCKDOWN`) + +- `com.kuhy.focus_owner.action.LOCKDOWN_UNTIL_EOD` — extra `until` + (`YYYY-MM-DD`, inclusive). Missing extra = today (old senders keep working). +- `com.kuhy.focus_owner.action.LIFT_LOCKDOWN` — clears any lockdown now. + +### PC files (`~/.local/state/wake_alarm/`) + +- `events.jsonl` — every PC event (`_log_sink`), `event` key names below. +- `fallback_ring.json` — today's latch: `{"date", "started_at", "stopped_at", + "reason", "stop_reason"}`. +- `workday_penalty.json` — unchanged shape; not written for an escaped day, + and removed when an escape arrives for the day it was issued. + +### Event names + +Phone (`phone_events.kind`): `alarm_fired` (data: `reason` = +`daily`|`rering`|`free_day` — only `daily` is the morning's ring), +`ring_screen_opened`, `ring_started_published` (data: `date`, `source` = +`alarm`|`ring_screen`), `dismissed`, `stick_check` (data: `trigger` = +`exact`|`boot`|`watchdog`, `date` judged, `verdict`), `lockdown_sent` (data: +`until`, `streak`), `lift_sent`, `escape_used`, `boot`. + +`ring_started` is published twice over: a headless Dart engine started by +`AlarmReceiver` at the fire (so a full-screen intent downgraded to a heads-up +on an unlocked phone still counts) and the ring screen; first of the day wins. +The A+15 check uses `setExactAndAllowWhileIdle` (falls back to +`setAlarmClock` only when exact alarms are refused), so it never shows as the +system's next alarm. Boot/watchdog catch up on up to 5 unchecked past days. + +PC (`events.jsonl` `event`): `fallback_tick` (only on decision change), +`fallback_ring_start`, `fallback_ring_stop`, `ring_backend`, `rtc_armed`, +`rtc_skip`, `firebase_unreachable`, `escape_seen`, `penalty_written`, +`penalty_cancelled`, `audit_written`. + +## Evidence for Claude + +`python -m wake_alarm._audit [YYYY-MM-DD]` and the 11:05 timer write +`~/data/wake-alarm/incidents/.md`: a merged phone + PC timeline, then +expected-vs-observed checks (alarm known, RTC armed the night before, PC up +by `A`, ring/fallback, penalty, lockdown), mismatches and escape uses first. +Outside the repo on purpose: it holds personal data. diff --git a/install.sh b/install.sh index 667c4de..5d56fb6 100755 --- a/install.sh +++ b/install.sh @@ -3,20 +3,26 @@ # # Usage: bash install.sh # -# The PC no longer wakes anyone -- the phone does. All this installs is the -# per-minute trigger that starts morning-routine.service (and therefore the -# workout lock) at the time the phone last synced. +# The phone is the alarm. The PC starts the morning routine at the time the +# phone last synced, and -- wake enforcement v2, DOCS-wake-enforcement.md -- +# rings itself only when the phone stays silent, and wakes itself (RTC) +# before a Tue/Wed/Thu alarm. # # What it does: # 1. Installs wake_alarm + dependencies for /usr/bin/python -# 2. Installs the systemd user units and enables the per-minute trigger timer -# 3. Removes the retired waking machinery from earlier installs +# 2. Installs the systemd user units and enables their timers (trigger, +# session, fallback, rtc, audit) +# 3. Installs the root pieces of the fallback alarm: the ring WAVs, the +# ALSA ring script + system unit, the sudoers lines that let the user +# start/stop that unit, and (pc_wake.json "hibernate" mode only) the +# logind drop-in that makes the power key hibernate +# 4. Removes the retired waking machinery from earlier installs # -# Deliberately NOT done any more, and why: -# - no shutdown wrapper, no rtcwake, no sleep hook: the PC does not wake you -# - the /etc/sudoers.d/wake-alarm entry is left ALONE. screen-locker's own -# _wake_alarm.py still shells out to `sudo rtcwake`, so removing it here -# would silently break a live feature in another repo. +# Deliberately NOT done, and why: +# - no shutdown wrapper and no sleep hook: the RTC alarm lives in CMOS and +# survives however the PC goes down, so neither is needed +# - systemd-logind is never restarted (that kills the graphical session); +# the drop-in is applied with a HUP reload set -euo pipefail @@ -42,10 +48,81 @@ readonly TRIGGER_TIMER_SRC="$SCRIPT_DIR/wake-alarm-trigger.timer" readonly SESSION_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-session.service" readonly SESSION_TIMER_SRC="$SCRIPT_DIR/wake-alarm-session.timer" readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user" +# Enforcement v2 user units (service + timer each). +readonly V2_UNITS=(wake-alarm-fallback wake-alarm-rtc wake-alarm-audit) +# Root pieces of the fallback alarm. +readonly PC_DIR="$SCRIPT_DIR/scripts/pc" +readonly WAV_DIR="/usr/local/share/wake-alarm" +readonly RING_SCRIPT_DST="/usr/local/lib/wake-alarm/ring_alsa.sh" +readonly ALSA_UNIT="wake-alarm-ring-alsa.service" +readonly SUDOERS_FILE="/etc/sudoers.d/wake-alarm" +readonly POWERKEY_DST="/etc/systemd/logind.conf.d/wake-alarm-powerkey.conf" +INSTALL_USER="$(id -un)" +readonly INSTALL_USER +readonly SUDOERS_LINES=( + "$INSTALL_USER ALL=(root) NOPASSWD: /usr/bin/systemctl start $ALSA_UNIT" + "$INSTALL_USER ALL=(root) NOPASSWD: /usr/bin/systemctl stop $ALSA_UNIT" +) + +TEMP_DIR="" +cleanup() { + if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then + rm -rf "$TEMP_DIR" + fi +} +trap cleanup EXIT + +install_ring_audio() { + # Generated, never committed: the binary hook forbids WAVs in the repo. + /usr/bin/python3 -m wake_alarm._ring_wav --write "$TEMP_DIR/wav" >/dev/null + sudo install -D -m 0644 "$TEMP_DIR/wav/ring.wav" "$WAV_DIR/ring.wav" + sudo install -D -m 0644 "$TEMP_DIR/wav/ring_loop.wav" "$WAV_DIR/ring_loop.wav" + sudo install -D -m 0755 "$PC_DIR/ring_alsa.sh" "$RING_SCRIPT_DST" + sudo install -m 0644 "$PC_DIR/$ALSA_UNIT" "/etc/systemd/system/$ALSA_UNIT" + sudo systemctl daemon-reload + echo " Ring WAVs, $RING_SCRIPT_DST and $ALSA_UNIT installed." +} + +install_sudoers() { + # Merge, never clobber: the file also holds the rtcwake entry the RTC + # arming relies on. Validated with visudo before it replaces anything. + local merged="$TEMP_DIR/sudoers" line + # The file is root-only (0440); a missing one starts the merge empty. + sudo cat "$SUDOERS_FILE" 2>/dev/null | tee "$merged" >/dev/null || : >"$merged" + if ! grep -q '/usr/sbin/rtcwake' "$merged"; then + echo "$INSTALL_USER ALL=(root) NOPASSWD: /usr/sbin/rtcwake" >>"$merged" + fi + for line in "${SUDOERS_LINES[@]}"; do + grep -qxF "$line" "$merged" || echo "$line" >>"$merged" + done + sudo visudo -cf "$merged" >/dev/null + sudo install -m 0440 -o root -g root "$merged" "$SUDOERS_FILE" + echo " $SUDOERS_FILE: rtcwake + start/stop $ALSA_UNIT (validated)." +} + +install_powerkey_dropin() { + local mode + mode="$(/usr/bin/python3 -m wake_alarm._pc_wake_mode)" + if [[ "$mode" == "hibernate" ]]; then + if sudo cmp -s "$PC_DIR/wake-alarm-powerkey.conf" "$POWERKEY_DST"; then + echo " Power key already hibernates (mode: $mode)." + return 0 + fi + sudo install -D -m 0644 "$PC_DIR/wake-alarm-powerkey.conf" "$POWERKEY_DST" + elif sudo test -f "$POWERKEY_DST"; then + sudo rm -f "$POWERKEY_DST" + else + echo " Power key untouched (mode: $mode)." + return 0 + fi + # Reload, NEVER restart: restarting logind kills the graphical session. + sudo systemctl kill -s HUP systemd-logind + echo " logind drop-in updated for mode $mode and reloaded." +} echo "=== Wake Alarm Trigger Installer ===" -echo "[1/3] Installing wake_alarm for /usr/bin/python..." +echo "[1/4] Installing wake_alarm for /usr/bin/python..." # Editable, and from this clone: a later `git pull` here then updates the # running production code with no reinstall. A non-editable snapshot taken # from a scratch directory silently stops tracking this repo. @@ -54,17 +131,31 @@ echo " Installed. Verifying import..." /usr/bin/python3 -c "import wake_alarm; import gatelock" echo " wake_alarm and gatelock import under /usr/bin/python." -echo "[2/3] Installing systemd user units..." +echo "[2/4] Installing systemd user units..." mkdir -p "$SYSTEMD_USER_DIR" cp "$TRIGGER_SERVICE_SRC" "$TRIGGER_TIMER_SRC" \ "$SESSION_SERVICE_SRC" "$SESSION_TIMER_SRC" "$SYSTEMD_USER_DIR/" +for unit in "${V2_UNITS[@]}"; do + cp "$SCRIPT_DIR/$unit.service" "$SCRIPT_DIR/$unit.timer" "$SYSTEMD_USER_DIR/" +done systemctl --user daemon-reload systemctl --user enable --now wake-alarm-trigger.timer echo " Trigger timer enabled and started (ticks once a minute)." systemctl --user enable --now wake-alarm-session.timer echo " Session timer enabled (07:00-11:00, every 2 min)." +for unit in "${V2_UNITS[@]}"; do + systemctl --user enable --now "$unit.timer" +done +echo " Fallback (04-09, every min), RTC (every 15 min + boot) and audit" +echo " (11:05) timers enabled." -echo "[3/3] Removing retired waking machinery from earlier installs..." +echo "[3/4] Installing the fallback alarm's root pieces (sudo)..." +TEMP_DIR="$(mktemp -d)" +install_ring_audio +install_sudoers +install_powerkey_dropin + +echo "[4/4] Removing retired waking machinery from earlier installs..." # Opportunistic, and deliberately NOT fatal. This script runs under `set -e` # and the cleanup needs root, so on a machine without cached/passwordless sudo # a hard call would abort the install *after* the package and timer are @@ -88,3 +179,6 @@ echo "time the phone last synced." echo "Check what it resolves right now: python -m wake_alarm._sync --show" echo "Check what the next tick would do: python -m wake_alarm._trigger --dry-run" echo "Watch it live: journalctl --user -fu wake-alarm-trigger.service" +echo "Fallback alarm: python -m wake_alarm._fallback --dry-run" +echo "RTC plan: python -m wake_alarm._rtc --show" +echo "Audit a morning: python -m wake_alarm._audit --dry-run [YYYY-MM-DD]" diff --git a/phone_app/android/app/src/main/AndroidManifest.xml b/phone_app/android/app/src/main/AndroidManifest.xml index 0df1047..92dd568 100644 --- a/phone_app/android/app/src/main/AndroidManifest.xml +++ b/phone_app/android/app/src/main/AndroidManifest.xml @@ -84,6 +84,11 @@ + + diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt index 08ee700..f10e6c2 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt @@ -143,9 +143,13 @@ object AlarmChannel { // means the ring was dismissed. "markRingDismissed" -> { RingWatch.markDismissed(context) + StickRunner.onDismissed(context) + EventLog.append(context, "dismissed") result.success(null) } - else -> result.notImplemented() + else -> if (!EnforcementChannel.handleCall(context, call, result)) { + result.notImplemented() + } } } diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmReceiver.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmReceiver.kt index 09251d3..c9258c8 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmReceiver.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmReceiver.kt @@ -21,6 +21,7 @@ class AlarmReceiver : BroadcastReceiver() { // re-arm -- that is the wake alarm's business, not this one's. if (intent.getStringExtra(AlarmScheduler.EXTRA_REASON) == AlarmScheduler.REASON_RERING) { Log.i(TAG, "session deadline missed: re-ringing") + EventLog.append(context, "alarm_fired", mapOf("reason" to "rering")) AlarmService.start(context) return } @@ -29,6 +30,7 @@ class AlarmReceiver : BroadcastReceiver() { // because the armed instant has passed) so the day after is normal. if (FreeDays.isToday(context)) { Log.i(TAG, "free day: not ringing; re-armed for tomorrow") + EventLog.append(context, "alarm_fired", mapOf("reason" to "free_day")) AlarmScheduler.rearmFromStorage(context) return } @@ -37,6 +39,10 @@ class AlarmReceiver : BroadcastReceiver() { // already woken the CPU by the time this line runs. RingWatch.markFired(context) AlarmService.start(context) + EventLog.append(context, "alarm_fired", mapOf("reason" to "daily")) + // After the sound is started, never before: tells the PC the phone + // is ringing even when the ring screen never opens. + HeadlessRingStart.start(context) } private companion object { diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt index 8bc832c..281c748 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt @@ -6,6 +6,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build +import java.time.LocalTime import java.util.Calendar /** @@ -61,12 +62,16 @@ object AlarmScheduler { AlarmManager.AlarmClockInfo(at, showIntent(context)), firePendingIntent(context), ) + // Every arm path (picker, boot, dismiss, free-day skip, watchdog) + // ends here, so the stick's own check can never be left unarmed. + StickAlarm.schedule(context, LocalTime.of(hour, minute)) } /** Cancels the armed alarm and forgets the stored time. */ fun cancel(context: Context) { val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager manager.cancel(firePendingIntent(context)) + StickAlarm.cancel(context) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() .remove(KEY_HOUR).remove(KEY_MINUTE).apply() } @@ -86,6 +91,15 @@ object AlarmScheduler { return true } + /** The stored daily time `A`, or null when none was ever armed. */ + fun storedAlarm(context: Context): LocalTime? { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val hour = prefs.getInt(KEY_HOUR, NONE) + val minute = prefs.getInt(KEY_MINUTE, NONE) + if (hour == NONE || minute == NONE) return null + return LocalTime.of(hour, minute) + } + /** * Arms the morning session's re-ring for the absolute instant [atMillis]. * diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmWatchdog.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmWatchdog.kt index c0e35d7..fb61639 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmWatchdog.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmWatchdog.kt @@ -54,10 +54,9 @@ object AlarmWatchdog { fun verify(context: Context) { val rearmed = AlarmScheduler.rearmFromStorage(context) Log.i(TAG, "daily check: rearmed=$rearmed") - // Fallback net for an abandoned ring on a phone that never rebooted - // (BootReceiver already covers the poweroff case): within a day, - // whatever the reason, WorkdayStick catches it here too. - WorkdayStick.checkAndSignal(context) + // Third net behind the exact check and boot: within a day, whatever + // dropped the other two, today's miss is still caught here. + StickRunner.run(context, "watchdog") } private fun pendingIntent(context: Context): PendingIntent = diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/BootReceiver.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/BootReceiver.kt index df43401..0f4368d 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/BootReceiver.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/BootReceiver.kt @@ -20,13 +20,15 @@ class BootReceiver : BroadcastReceiver() { // a reboot, either -- see restoreReRing for the launch-time replay. val rering = AlarmScheduler.restoreReRing(context) Log.i(TAG, "boot: rearmed=$rearmed rering=$rering") + EventLog.append(context, "boot", mapOf("rearmed" to rearmed, "rering" to rering)) // setInexactRepeating does not survive a reboot, so the daily check // has to be re-established here or it silently stops after the first // restart — exactly when a watchdog is most needed. AlarmWatchdog.schedule(context) - // The moment a phone powered off mid-ring comes back is exactly when - // an abandoned ring should be caught -- see WorkdayStick. - WorkdayStick.checkAndSignal(context) + // A phone switched off before the alarm and back on after A + 15 has + // missed: this is the path that closes that hole (rearmFromStorage + // above has already armed the exact check for a phone back earlier). + StickRunner.run(context, "boot") } private companion object { diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/DateHistory.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/DateHistory.kt new file mode 100644 index 0000000..52583f7 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/DateHistory.kt @@ -0,0 +1,31 @@ +package com.kuhy.wake_alarm_sync + +import android.content.SharedPreferences +import java.time.LocalDate + +/** + * A bounded set of ISO dates in one SharedPreferences key: "on which days + * did X happen". The stick's backlog judges days up to + * [StickCheck.BACKLOG_DAYS] back, so a single "last date" is not enough -- + * a dismiss on Tuesday would be forgotten by Wednesday's. + */ +object DateHistory { + /** Kept comfortably longer than the backlog. */ + private const val KEEP_DAYS = 14L + + /** Adds [day] and drops entries older than [KEEP_DAYS]; `commit()`s. */ + fun add(prefs: SharedPreferences, key: String, day: LocalDate) { + val floor = day.minusDays(KEEP_DAYS) + val kept = read(prefs, key).filter { iso -> + runCatching { !LocalDate.parse(iso).isBefore(floor) }.getOrDefault(false) + }.toSet() + day.toString() + prefs.edit().putStringSet(key, kept).commit() + } + + fun contains(prefs: SharedPreferences, key: String, day: LocalDate): Boolean = + day.toString() in read(prefs, key) + + // A copy: the set SharedPreferences returns must not be modified. + private fun read(prefs: SharedPreferences, key: String): Set = + prefs.getStringSet(key, emptySet())?.toSet() ?: emptySet() +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/EnforcementChannel.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/EnforcementChannel.kt new file mode 100644 index 0000000..914eb2e --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/EnforcementChannel.kt @@ -0,0 +1,71 @@ +package com.kuhy.wake_alarm_sync + +import android.content.Context +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.time.LocalDate + +/** + * The wake-enforcement methods on the one alarm channel ([AlarmChannel] + * delegates its unknown methods here, so that file stays under the cap). + * + * Dart reads the stick and the event log through this, appends its own + * events to the same log, and uses the escape hatch. Nothing here can lock + * or arm anything: the stick is decided natively by [StickRunner]. + */ +object EnforcementChannel { + + /** Handles [call] if it is one of ours; false leaves it unanswered. */ + fun handleCall(context: Context, call: MethodCall, result: MethodChannel.Result): Boolean { + when (call.method) { + "appendEvent" -> { + val kind = call.argument("kind") + if (kind == null) { + result.error("bad_args", "kind is required", null) + } else { + val data = call.argument>("data") ?: emptyMap() + EventLog.append(context, kind, data) + result.success(null) + } + } + "readEvents" -> result.success(EventLog.readJson(context)) + "enforcementState" -> result.success(state(context)) + // False once the hatch has closed; see EscapeHatch. + "escapeToday" -> result.success(EscapeHatch.use(context)) + else -> return false + } + return true + } + + /** + * Everything the stakes card and the ring line show, as plain values. + * + * `lockIfMiss*` comes from [StickCheck.afterMiss] -- the same function + * the enforcement runs -- so the date the screen promises is the date a + * miss would actually send. + */ + private fun state(context: Context): Map { + val today = LocalDate.now() + val tomorrow = today.plusDays(1) + val stick = StickStore.load(context) + val alarm = AlarmScheduler.storedAlarm(context) + val isFree = { day: LocalDate -> FreeDays.isFree(context, day) } + return mapOf( + "today" to today.toString(), + "alarmHour" to alarm?.hour, + "alarmMinute" to alarm?.minute, + "dismissedToday" to RingWatch.dismissedOn(context, today), + "escapedToday" to StickStore.escapedOn(context, today), + "freeToday" to isFree(today), + "freeTomorrow" to isFree(tomorrow), + "streak" to stick.streak, + "lockedThrough" to stick.lockedThrough?.toString(), + "lastMiss" to stick.lastMiss?.toString(), + "updatedAt" to StickStore.updatedAt(context), + "lockIfMissToday" to + StickCheck.afterMiss(today, stick, isFree).lockedThrough?.toString(), + "lockIfMissTomorrow" to + StickCheck.afterMiss(tomorrow, stick, isFree).lockedThrough?.toString(), + ) + } +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/EscapeHatch.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/EscapeHatch.kt new file mode 100644 index 0000000..5aa0dcd --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/EscapeHatch.kt @@ -0,0 +1,35 @@ +package com.kuhy.wake_alarm_sync + +import android.content.Context +import android.util.Log +import java.time.LocalDate + +/** + * "That was a bug": excuses today's morning on the phone side. + * + * Marks today escaped (so no trigger can penalise it), clears the streak and + * any lockdown, tells focus-owner to lift, and logs both. The PC side reads + * the `escapes` field Dart publishes afterwards. Refused natively after + * [Stakes.escapeHatchLastDay] as well -- hiding the button is not the only + * guard. + */ +object EscapeHatch { + private const val TAG = "WakeAlarmEscape" + + fun isOpen(today: LocalDate): Boolean = !today.isAfter(Stakes.escapeHatchLastDay) + + /** False when the hatch has closed; nothing is changed then. */ + @Synchronized + fun use(context: Context): Boolean { + val today = LocalDate.now() + if (!isOpen(today)) { + Log.w(TAG, "escape refused: the hatch closed after ${Stakes.escapeHatchLastDay}") + return false + } + StickStore.markEscaped(context, today) + EventLog.append(context, "escape_used", mapOf("date" to today.toString())) + WorkdayStick.sendLift(context) + EventLog.append(context, "lift_sent") + return true + } +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/EventLog.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/EventLog.kt new file mode 100644 index 0000000..de72dfe --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/EventLog.kt @@ -0,0 +1,77 @@ +package com.kuhy.wake_alarm_sync + +import android.content.Context +import android.util.Log +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +/** + * The phone's bounded event log: `{"at", "kind", "data"}`, newest first, + * at most [MAX_ENTRIES] entries and [MAX_AGE_DAYS] days. + * + * Native, so the receivers that run with no Flutter engine (the alarm, the + * stick check, boot) can write it; Dart appends to the same log over the + * alarm channel and stages it as `phone_events` on every sync. This is the + * evidence the daily incident report merges with the PC's -- a morning that + * went wrong is reconstructed from here, not from memory. + */ +object EventLog { + private const val TAG = "WakeAlarmEvents" + private const val PREFS = "wake_alarm_events" + private const val KEY_EVENTS = "events" + const val MAX_ENTRIES = 400 + const val MAX_AGE_DAYS = 14L + + /** Seconds precision with an explicit offset, which `toString()` drops at :00. */ + private val FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX") + + fun nowIso(): String = OffsetDateTime.now().format(FORMAT) + + /** Appends one event stamped now. Never throws: logging must not break a ring. */ + @Synchronized + fun append(context: Context, kind: String, data: Map = emptyMap()) { + try { + val entry = JSONObject() + .put("at", nowIso()) + .put("kind", kind) + .put("data", JSONObject(data.filterValues { it != null })) + val kept = JSONArray().put(entry) + val cutoff = OffsetDateTime.now().minusDays(MAX_AGE_DAYS) + val old = read(context) + for (i in 0 until old.length()) { + if (kept.length() >= MAX_ENTRIES) break + val event = old.optJSONObject(i) ?: continue + if (isAfter(event.optString("at"), cutoff)) kept.put(event) + } + prefs(context).edit().putString(KEY_EVENTS, kept.toString()).commit() + } catch (e: JSONException) { + Log.w(TAG, "event $kind not logged", e) + } + } + + /** The whole log as a JSON array string, newest first. */ + @Synchronized + fun readJson(context: Context): String = read(context).toString() + + private fun read(context: Context): JSONArray = try { + JSONArray(prefs(context).getString(KEY_EVENTS, "[]") ?: "[]") + } catch (e: JSONException) { + // A corrupt log is replaced, not fatal: losing history beats losing + // every event from here on. + Log.w(TAG, "event log unreadable; starting over", e) + JSONArray() + } + + private fun isAfter(at: String, cutoff: OffsetDateTime): Boolean = try { + OffsetDateTime.parse(at).isAfter(cutoff) + } catch (e: DateTimeParseException) { + false + } + + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/FreeDays.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/FreeDays.kt index 50edc04..955dfec 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/FreeDays.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/FreeDays.kt @@ -31,10 +31,17 @@ object FreeDays { } /** Whether today (local calendar day) is in the cached set. */ - fun isToday(context: Context): Boolean = try { + fun isToday(context: Context): Boolean = isFree(context, LocalDate.now()) + + /** + * Whether [day] is in the cached set. Past free days stay in it (Dart + * hands over every free record), which is what lets the stick's streak + * skip them. + */ + fun isFree(context: Context, day: LocalDate): Boolean = try { val dates = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getStringSet(KEY_DATES, emptySet()) ?: emptySet() - dates.contains(LocalDate.now().toString()) + dates.contains(day.toString()) } catch (e: RuntimeException) { Log.w(TAG, "free-day cache unreadable; ringing as normal", e) false diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/HeadlessRingStart.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/HeadlessRingStart.kt new file mode 100644 index 0000000..a88f345 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/HeadlessRingStart.kt @@ -0,0 +1,71 @@ +package com.kuhy.wake_alarm_sync + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import io.flutter.FlutterInjector +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.plugin.common.MethodChannel + +/** + * Publishes `ring_started` the moment the daily alarm fires, with no screen. + * + * The ring screen used to be the only source, and it does not always open: + * on an unlocked phone in use the full-screen intent is downgraded to a + * heads-up notification. Then no `ring_started` reached the PC, which rang + * `phone_silent` at a user who was awake and holding the phone. So the + * alarm itself starts a headless Flutter engine running `ringStartedMain` + * (`lib/main.dart` → `headless_ring_start.dart`), which records and pushes + * through the same `RingStartRecorder` as the screen. The first ring of the + * day wins; the screen's later publish is skipped once this one landed. + * + * The engine lives while [AlarmService] holds the process in the + * foreground. Dart says `done` on [CHANNEL]; [TIMEOUT_MS] destroys it + * regardless, so a hung network call cannot keep a second engine forever. + * Never throws: a failure here must never touch the ring. + */ +object HeadlessRingStart { + private const val TAG = "WakeAlarmHeadless" + const val CHANNEL = "com.kuhy.wake_alarm_sync/headless" + private const val ENTRYPOINT = "ringStartedMain" + private const val TIMEOUT_MS = 90_000L + + private val handler = Handler(Looper.getMainLooper()) + private var engine: FlutterEngine? = null + + /** Starts the engine unless one is already running. Main thread only. */ + fun start(context: Context) { + if (engine != null) return + try { + val app = context.applicationContext + val created = FlutterEngine(app) + engine = created + AlarmChannel.register(app, created) + MethodChannel(created.dartExecutor.binaryMessenger, CHANNEL) + .setMethodCallHandler { call, result -> + result.success(null) + // Posted: destroying an engine inside its own callback + // would tear the messenger down under the reply. + if (call.method == "done") handler.post { stop("done") } + } + val bundle = FlutterInjector.instance().flutterLoader().findAppBundlePath() + created.dartExecutor.executeDartEntrypoint( + DartExecutor.DartEntrypoint(bundle, ENTRYPOINT), + ) + handler.postDelayed({ stop("timeout") }, TIMEOUT_MS) + Log.i(TAG, "headless ring_started engine started") + } catch (e: RuntimeException) { + Log.w(TAG, "headless engine failed; the ring screen still publishes", e) + stop("failed") + } + } + + private fun stop(reason: String) { + handler.removeCallbacksAndMessages(null) + engine?.destroy() + engine = null + Log.i(TAG, "headless engine stopped: $reason") + } +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/RingWatch.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/RingWatch.kt index ad61a1c..9033f3c 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/RingWatch.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/RingWatch.kt @@ -2,6 +2,7 @@ package com.kuhy.wake_alarm_sync import android.content.Context import java.text.SimpleDateFormat +import java.time.LocalDate import java.util.Calendar import java.util.Locale @@ -21,14 +22,16 @@ import java.util.Locale * the initial dismiss gate actually completes (`session_handoff.dart`'s * `afterDismiss`). * - * [abandonedRingDate] is non-null exactly when a ring fired and nothing - * since has dismissed it -- checked from [BootReceiver] and the daily - * [WatchdogReceiver] tick, both of which run without a Flutter engine. + * The stick ([StickCheck]) reads only [dismissedOn]. It used to ask "did a + * ring fire and go undismissed", which needs the *fired* date -- and a phone + * switched off before the alarm never fires, so that escaped everything. + * The fired date stays as evidence for whoever debugs a morning. */ object RingWatch { private const val PREFS = "wake_alarm_ring_watch" private const val KEY_FIRED_DATE = "fired_date" private const val KEY_DISMISSED_DATE = "dismissed_date" + private const val KEY_DISMISSED_DATES = "dismissed_dates" fun markFired(context: Context) { prefs(context).edit().putString(KEY_FIRED_DATE, today()).apply() @@ -36,14 +39,17 @@ object RingWatch { fun markDismissed(context: Context) { prefs(context).edit().putString(KEY_DISMISSED_DATE, today()).apply() + DateHistory.add(prefs(context), KEY_DISMISSED_DATES, LocalDate.now()) } - /** The date a ring fired but was never dismissed, or null. */ - fun abandonedRingDate(context: Context): String? { - val fired = prefs(context).getString(KEY_FIRED_DATE, null) ?: return null - val dismissed = prefs(context).getString(KEY_DISMISSED_DATE, null) - return if (fired == dismissed) null else fired - } + /** + * Whether the dismiss gate completed on [day]; read by [StickRunner], + * which judges past days too. The single [KEY_DISMISSED_DATE] predates + * the history and still answers for its one day. + */ + fun dismissedOn(context: Context, day: LocalDate): Boolean = + DateHistory.contains(prefs(context), KEY_DISMISSED_DATES, day) || + prefs(context).getString(KEY_DISMISSED_DATE, null) == day.toString() private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/Stakes.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/Stakes.kt new file mode 100644 index 0000000..b397600 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/Stakes.kt @@ -0,0 +1,25 @@ +package com.kuhy.wake_alarm_sync + +import java.time.LocalDate + +/** + * The native copies of the wake-enforcement numbers (`DOCS-wake-enforcement.md`). + * + * The stick decides with no Flutter engine running, so it cannot read + * `lib/services/stakes.dart`. `wake_alarm/tests/test_stakes_mirrors.py` + * fails when these drift from the Dart and Python copies -- keep each + * literal on one line so that test can parse it. + */ +object Stakes { + /** A ring not dismissed this long after the alarm is a miss (`SESSION_NONE_GRACE`). */ + const val STICK_GRACE_MINUTES = 15L + + /** A lockdown never spans more calendar days than this, today included. */ + const val STICK_LOCKDOWN_MAX_DAYS = 5 + + /** The escape hatch's last usable day, inclusive. */ + const val ESCAPE_HATCH_LAST_DAY = "2026-10-15" + + /** [ESCAPE_HATCH_LAST_DAY] as a date. */ + val escapeHatchLastDay: LocalDate = LocalDate.parse(ESCAPE_HATCH_LAST_DAY) +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickAlarm.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickAlarm.kt new file mode 100644 index 0000000..e8ccaf4 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickAlarm.kt @@ -0,0 +1,122 @@ +package com.kuhy.wake_alarm_sync + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeParseException + +/** + * The exact alarm at A + [Stakes.STICK_GRACE_MINUTES] that runs the stick + * check (trigger `exact`). + * + * [AlarmManager.setExactAndAllowWhileIdle], not setAlarmClock: an alarm + * clock entry would show in the system clock as the "next alarm" between A + * and A + 15. This check rings nothing, so it needs no alarm-clock status; + * `USE_EXACT_ALARM` (declared for the daily alarm) grants exact alarms at + * install. Only if exact alarms are not permitted after all does it fall + * back to setAlarmClock -- a visible chip beats a check that never runs. + * + * Its own receiver and request code ([REQUEST_CODE]), never [AlarmReceiver]: + * anything that reached that receiver without the re-ring extra would be + * treated as the daily alarm and *ring*. + * + * Armed from [AlarmScheduler.schedule], i.e. by every arm path. The instant + * is the next occurrence of (A + 15) on its own, and an instant already + * armed for today is kept against a later one ([StickSchedule.choose]): + * otherwise a re-arm between A and A + 15 -- the dismiss, the free-day + * skip, opening the app, boot -- would push today's check to tomorrow, + * which is exactly the window a phone switched back on lands in. + */ +object StickAlarm { + private const val REQUEST_CODE = 1004 + private const val SHOW_REQUEST_CODE = 1005 + private const val PREFS = "wake_alarm_stick_alarm" + private const val KEY_PENDING_AT = "pending_at" + private const val TAG = "WakeAlarmStickAlarm" + + fun schedule(context: Context, alarm: LocalTime) { + val now = LocalDateTime.now() + val at = StickSchedule.choose( + now, + StickSchedule.nextOccurrence(now, alarm), + pendingAt(context), + ) + // Always re-set, even when the pending instant is kept: a reboot + // drops every AlarmManager alarm, and boot is one of the callers. + prefs(context).edit().putString(KEY_PENDING_AT, at.toString()).commit() + val millis = at.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + val how = arm(context, millis) + Log.i(TAG, "stick check armed for $at via $how") + } + + /** Arms [millis] exactly; returns which API did it, for the log. */ + private fun arm(context: Context, millis: Long): String { + val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val exactAllowed = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + manager.canScheduleExactAlarms() + if (exactAllowed) { + try { + manager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + millis, + firePendingIntent(context), + ) + return "setExactAndAllowWhileIdle" + } catch (e: SecurityException) { + Log.w(TAG, "exact alarm refused; falling back to setAlarmClock", e) + } + } + manager.setAlarmClock( + AlarmManager.AlarmClockInfo(millis, showIntent(context)), + firePendingIntent(context), + ) + return "setAlarmClock" + } + + fun cancel(context: Context) { + val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + manager.cancel(firePendingIntent(context)) + prefs(context).edit().remove(KEY_PENDING_AT).commit() + } + + private fun pendingAt(context: Context): LocalDateTime? = try { + prefs(context).getString(KEY_PENDING_AT, null)?.let { LocalDateTime.parse(it) } + } catch (e: DateTimeParseException) { + null + } + + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + private fun firePendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( + context, + REQUEST_CODE, + Intent(context, StickCheckReceiver::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + private fun showIntent(context: Context): PendingIntent = PendingIntent.getActivity( + context, + SHOW_REQUEST_CODE, + Intent(context, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) +} + +/** Receives [StickAlarm]: runs the check, then arms tomorrow's. */ +class StickCheckReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + StickRunner.run(context, "exact") + // After an undismissed ring nothing else re-arms until the watchdog + // or the next app launch, so the check re-arms itself. The instant + // that just fired is now in the past, so choose() drops it. + AlarmScheduler.storedAlarm(context)?.let { StickAlarm.schedule(context, it) } + } +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickCheck.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickCheck.kt new file mode 100644 index 0000000..50bfbf7 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickCheck.kt @@ -0,0 +1,168 @@ +package com.kuhy.wake_alarm_sync + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +/** What one stick check concluded; [wire] is the `stick_check` event's `verdict`. */ +enum class StickVerdict(val wire: String, val final: Boolean = true) { + NO_ALARM("no_alarm"), + NOT_STICK_DAY("not_stick_day"), + FREE_DAY("free_day"), + ESCAPED("escaped"), + ALREADY_PENALISED("already_penalised"), + DISMISSED("dismissed"), + TOO_EARLY("too_early", final = false), + MISS("miss"), +} + +/** The persisted stick: the streak and the lockdown it bought. */ +data class StickState( + val streak: Int = 0, + val lockedThrough: LocalDate? = null, + val lastMiss: LocalDate? = null, +) + +/** Everything one run reads; gathered by [StickRunner], decided here. */ +data class StickInputs( + val now: LocalDateTime, + /** The stored daily alarm `A`, or null when none is stored. */ + val alarm: LocalTime?, + val dismissedOn: (LocalDate) -> Boolean, + val escapedOn: (LocalDate) -> Boolean, + /** The native free-day cache; fail-closed, so unreadable is `false`. */ + val isFree: (LocalDate) -> Boolean, + val state: StickState, + /** The last day whose verdict was final, or null before the first run. */ + val checkedThrough: LocalDate?, +) + +/** One day's verdict within a run. */ +data class DayVerdict(val day: LocalDate, val verdict: StickVerdict) + +/** + * A run's result: each day judged, the state to persist, how far days are + * now settled, and the lockdown to signal (null when no miss in this run + * locks through today or later). + */ +data class StickRun( + val verdicts: List, + val state: StickState, + val checkedThrough: LocalDate, + val signalUntil: LocalDate?, +) + +/** + * The stick's decision, as pure functions -- no Context, no clock, no I/O, + * so every rule here is checkable in isolation. + * + * A **miss** is a stick day (Tue/Wed/Thu, [WorkdayStick.STICK_DAYS]) that is + * not a free day, whose alarm passed by at least [Stakes.STICK_GRACE_MINUTES] + * with no dismiss and no escape, and that has not already been penalised. + * Nothing here needs the ring to have *fired*: a phone switched off before + * the alarm misses exactly like one left ringing (hole 1 of the contract). + */ +object StickCheck { + + /** How many past days a run catches up on (a phone off for days). */ + const val BACKLOG_DAYS = 5L + + /** How far back [previousStickDay] looks before giving up. */ + private const val LOOKBACK_DAYS = 60L + + /** + * Judges every unchecked day, oldest first, then today. + * + * Past days run from the day after [StickInputs.checkedThrough], at most + * [BACKLOG_DAYS] back -- in order, so the streak builds exactly as if + * each day had been checked on time. A first run (no checkedThrough) + * judges today only: days from before this stick existed have no + * dismiss records and must not be punished. + */ + fun run(inputs: StickInputs): StickRun { + val today = inputs.now.toLocalDate() + val verdicts = mutableListOf() + var state = inputs.state + var signal: LocalDate? = null + val checked = inputs.checkedThrough + var day = if (checked == null) { + today + } else { + maxOf(checked.plusDays(1), today.minusDays(BACKLOG_DAYS)) + } + if (day.isAfter(today)) day = today + while (!day.isAfter(today)) { + val at = if (day == today) inputs.now else day.atTime(LocalTime.MAX) + val (verdict, next) = decideDay(day, at, inputs, state) + verdicts += DayVerdict(day, verdict) + if (verdict == StickVerdict.MISS) { + signal = next.lockedThrough?.takeIf { !it.isBefore(today) } + } + state = next + day = day.plusDays(1) + } + val settled = if (verdicts.last().verdict.final) today else today.minusDays(1) + return StickRun(verdicts, state, maxOf(settled, checked ?: settled), signal) + } + + /** One day's verdict and the state after it; [now] is when it is judged. */ + fun decideDay( + day: LocalDate, + now: LocalDateTime, + inputs: StickInputs, + state: StickState, + ): Pair { + val alarm = inputs.alarm ?: return StickVerdict.NO_ALARM to state + if (!isStickDay(day)) return StickVerdict.NOT_STICK_DAY to state + if (inputs.isFree(day)) return StickVerdict.FREE_DAY to state + // A dismissed or escaped stick day breaks the chain: the streak + // shown and synced drops to 0 (a lockdown already sent stays). + if (inputs.escapedOn(day)) return StickVerdict.ESCAPED to state.copy(streak = 0) + if (state.lastMiss == day) return StickVerdict.ALREADY_PENALISED to state + if (inputs.dismissedOn(day)) return StickVerdict.DISMISSED to state.copy(streak = 0) + val due = day.atTime(alarm).plusMinutes(Stakes.STICK_GRACE_MINUTES) + if (now.isBefore(due)) return StickVerdict.TOO_EARLY to state + return StickVerdict.MISS to afterMiss(day, state, inputs.isFree) + } + + /** + * The state a miss on [day] would produce. Also what the UI shows as + * "locked through" before the miss happens, so the screen and the + * enforcement can never disagree about the date. Never shortens a + * lockdown already in force: a chain restarting at 1 must not report + * an earlier end than the one focus-owner was already sent. + */ + fun afterMiss(day: LocalDate, state: StickState, isFree: (LocalDate) -> Boolean): StickState { + val streak = streakAfterMiss(day, state, isFree) + val computed = lockedThrough(day, streak) + val locked = state.lockedThrough?.let { maxOf(it, computed) } ?: computed + return StickState(streak = streak, lockedThrough = locked, lastMiss = day) + } + + /** + * Consecutive stick-day misses including one on [day]: the chain + * continues only when the previous stick day (free days skipped) is + * exactly the last miss. + */ + fun streakAfterMiss(day: LocalDate, state: StickState, isFree: (LocalDate) -> Boolean): Int { + val previous = previousStickDay(day, isFree) + return if (previous != null && previous == state.lastMiss) state.streak + 1 else 1 + } + + /** `day + min(streak, max - 1)`: 1 → the next day, 4+ → five calendar days. */ + fun lockedThrough(day: LocalDate, streak: Int): LocalDate = + day.plusDays(minOf(streak, Stakes.STICK_LOCKDOWN_MAX_DAYS - 1).toLong()) + + /** The nearest earlier stick day that is not a free day, or null. */ + fun previousStickDay(day: LocalDate, isFree: (LocalDate) -> Boolean): LocalDate? { + var step = day.minusDays(1) + val floor = day.minusDays(LOOKBACK_DAYS) + while (step.isAfter(floor)) { + if (isStickDay(step) && !isFree(step)) return step + step = step.minusDays(1) + } + return null + } + + fun isStickDay(day: LocalDate): Boolean = day.dayOfWeek in WorkdayStick.STICK_DAYS +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickRunner.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickRunner.kt new file mode 100644 index 0000000..ff17175 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickRunner.kt @@ -0,0 +1,75 @@ +package com.kuhy.wake_alarm_sync + +import android.content.Context +import android.util.Log +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * Runs the stick: gathers [StickInputs] from the native stores, asks + * [StickCheck], and on a miss persists the new state, signals focus-owner and + * logs it. The only place those three are wired together. + * + * Three triggers share this one decision, so they can never disagree: + * `exact` (the [StickAlarm] at A + 15), `boot` (closes "phone off before the + * alarm") and `watchdog` (the daily net). Every run also catches up on the + * unchecked days before today ([StickCheck.run]), so a phone off for days + * is judged day by day when it comes back. Running twice is harmless -- + * [StickVerdict.ALREADY_PENALISED] makes a second miss on one day a no-op, + * which matters because Android replays BOOT_COMPLETED to a force-stopped + * app on its next launch. + */ +object StickRunner { + private const val TAG = "WakeAlarmStick" + + @Synchronized + fun run(context: Context, trigger: String): StickVerdict { + val before = StickStore.load(context) + val run = StickCheck.run( + StickInputs( + now = LocalDateTime.now(), + alarm = AlarmScheduler.storedAlarm(context), + dismissedOn = { RingWatch.dismissedOn(context, it) }, + escapedOn = { StickStore.escapedOn(context, it) }, + isFree = { FreeDays.isFree(context, it) }, + state = before, + checkedThrough = StickStore.checkedThrough(context), + ), + ) + for ((day, verdict) in run.verdicts) { + Log.i(TAG, "check ($trigger) $day: ${verdict.wire}") + EventLog.append( + context, + "stick_check", + mapOf("trigger" to trigger, "verdict" to verdict.wire, "date" to day.toString()), + ) + } + if (run.state != before) StickStore.save(context, run.state) + StickStore.setCheckedThrough(context, run.checkedThrough) + // A missed day whose lockdown already ended (caught up days later) + // still counts toward the streak, but nothing is sent for it. + run.signalUntil?.let { until -> + WorkdayStick.sendLockdown(context, until) + EventLog.append( + context, + "lockdown_sent", + mapOf("until" to until.toString(), "streak" to run.state.streak), + ) + } + return run.verdicts.last().verdict + } + + /** + * A dismissed stick day breaks the chain: the streak shown and synced + * drops to 0 at once. A lockdown already running is left alone, and a + * dismiss *after* today's miss changes nothing -- the miss stands. + */ + @Synchronized + fun onDismissed(context: Context) { + val today = LocalDate.now() + if (!StickCheck.isStickDay(today) || FreeDays.isFree(context, today)) return + val state = StickStore.load(context) + if (state.lastMiss == today || state.streak == 0) return + StickStore.save(context, state.copy(streak = 0)) + } +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickSchedule.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickSchedule.kt new file mode 100644 index 0000000..550be33 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickSchedule.kt @@ -0,0 +1,36 @@ +package com.kuhy.wake_alarm_sync + +import java.time.LocalDateTime +import java.time.LocalTime + +/** + * When [StickAlarm] fires, as pure functions of the clock, the alarm time + * and the check already armed -- no Context, so every case is checkable in + * isolation. + */ +object StickSchedule { + + /** A few seconds past the boundary, so "passed by >= 15 min" is never a rounding call. */ + private const val SLACK_SECONDS = 5L + + /** The next (A + grace + slack) strictly after [now]. */ + fun nextOccurrence(now: LocalDateTime, alarm: LocalTime): LocalDateTime { + val time = alarm.plusMinutes(Stakes.STICK_GRACE_MINUTES).plusSeconds(SLACK_SECONDS) + val candidate = now.toLocalDate().atTime(time) + return if (candidate.isAfter(now)) candidate else candidate.plusDays(1) + } + + /** + * The instant to arm: the earlier of [next] and a still-future [pending]. + * + * One PendingIntent holds one instant, so arming tomorrow's check would + * silently drop today's. Between A and A + 15 every re-arm passes the + * *next* alarm's time -- opening the app re-arms with tomorrow's policy, + * and "wake me earlier" sets an earlier one -- whose check is tomorrow. + * Keeping the pending instant is what stops "phone off, back on at A + 5, + * open the app" from cancelling today's check. A pending instant at or + * before [now] has fired (or was missed while off) and is dropped. + */ + fun choose(now: LocalDateTime, next: LocalDateTime, pending: LocalDateTime?): LocalDateTime = + if (pending != null && pending.isAfter(now) && pending.isBefore(next)) pending else next +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickStore.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickStore.kt new file mode 100644 index 0000000..5f494b1 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StickStore.kt @@ -0,0 +1,67 @@ +package com.kuhy.wake_alarm_sync + +import android.content.Context +import java.time.LocalDate + +/** + * Persists the stick ([StickState]) and the day an escape was used. + * + * Every write is `commit()`, not `apply()`: these run inside broadcast + * receivers (the exact check, BOOT_COMPLETED) whose process may be gone a + * moment later, and a miss recorded only in memory would be penalised again + * by the next trigger. [updatedAt] stamps the last change so the synced + * `stick_state` field is re-pushed only when something actually moved. + */ +object StickStore { + private const val PREFS = "wake_alarm_stick" + private const val KEY_STREAK = "streak" + private const val KEY_LOCKED_THROUGH = "locked_through" + private const val KEY_LAST_MISS = "last_miss" + private const val KEY_ESCAPED_DATES = "escaped_dates" + private const val KEY_CHECKED_THROUGH = "checked_through" + private const val KEY_UPDATED_AT = "updated_at" + + fun load(context: Context): StickState { + val prefs = prefs(context) + return StickState( + streak = prefs.getInt(KEY_STREAK, 0), + lockedThrough = parse(prefs.getString(KEY_LOCKED_THROUGH, null)), + lastMiss = parse(prefs.getString(KEY_LAST_MISS, null)), + ) + } + + fun save(context: Context, state: StickState) { + prefs(context).edit() + .putInt(KEY_STREAK, state.streak) + .putString(KEY_LOCKED_THROUGH, state.lockedThrough?.toString()) + .putString(KEY_LAST_MISS, state.lastMiss?.toString()) + .putString(KEY_UPDATED_AT, EventLog.nowIso()) + .commit() + } + + /** Records an escape for [day]: the streak and any lockdown are gone. */ + fun markEscaped(context: Context, day: LocalDate) { + save(context, load(context).copy(streak = 0, lockedThrough = null)) + DateHistory.add(prefs(context), KEY_ESCAPED_DATES, day) + } + + fun escapedOn(context: Context, day: LocalDate): Boolean = + DateHistory.contains(prefs(context), KEY_ESCAPED_DATES, day) + + /** The last day whose verdict was final; null before the first run. */ + fun checkedThrough(context: Context): LocalDate? = + parse(prefs(context).getString(KEY_CHECKED_THROUGH, null)) + + fun setCheckedThrough(context: Context, day: LocalDate) { + prefs(context).edit().putString(KEY_CHECKED_THROUGH, day.toString()).commit() + } + + /** ISO instant of the last change, or null before the first one. */ + fun updatedAt(context: Context): String? = prefs(context).getString(KEY_UPDATED_AT, null) + + private fun parse(iso: String?): LocalDate? = + iso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/WorkdayStick.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/WorkdayStick.kt index 465a80c..d75d800 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/WorkdayStick.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/WorkdayStick.kt @@ -3,13 +3,13 @@ package com.kuhy.wake_alarm_sync import android.content.Context import android.content.Intent import android.util.Log -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Locale +import java.time.DayOfWeek +import java.time.LocalDate /** - * Signals `focus-owner` to lock down distracting apps when a - * Tuesday/Wednesday/Thursday ring was abandoned -- see [RingWatch]. + * Signals `focus-owner` (`~/src/phone-focus-mode`): lock distracting apps + * through a date, or lift a lockdown now. Signalling only -- whether to lock + * is [StickCheck]'s decision, run by [StickRunner]. * * Tue/Wed/Thu is the same set `wake_alarm._constants.STICK_DAYS` uses on the * PC side for the debt-stacking half of this same stick: both go easy on @@ -17,32 +17,35 @@ import java.util.Locale * nothing. */ object WorkdayStick { - /** [Calendar.DAY_OF_WEEK] values for Tuesday/Wednesday/Thursday. */ - private val STICK_DAYS = - setOf(Calendar.TUESDAY, Calendar.WEDNESDAY, Calendar.THURSDAY) + /** Tuesday/Wednesday/Thursday; mirrored by `test_workday_mirrors.py`. */ + val STICK_DAYS = + setOf(DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY) private const val ACTION_LOCKDOWN = "com.kuhy.focus_owner.action.LOCKDOWN_UNTIL_EOD" + private const val ACTION_LIFT = "com.kuhy.focus_owner.action.LIFT_LOCKDOWN" + private const val EXTRA_UNTIL = "until" private const val PERMISSION_SIGNAL = "com.kuhy.focus_owner.permission.SIGNAL_LOCKDOWN" private const val FOCUS_OWNER_PACKAGE = "com.kuhy.focus_owner" private const val TAG = "WakeAlarmWorkdayStick" - /** Checks for an abandoned stick-day ring and signals focus-owner. */ - fun checkAndSignal(context: Context) { - val abandoned = RingWatch.abandonedRingDate(context) ?: return - if (!isStickDay(abandoned)) return - Log.i(TAG, "abandoned ring on $abandoned: signalling focus-owner lockdown") + /** Locks down through [until], inclusive (`yyyy-MM-dd` on the wire). */ + fun sendLockdown(context: Context, until: LocalDate) { + Log.i(TAG, "signalling focus-owner lockdown through $until") context.sendBroadcast( - Intent(ACTION_LOCKDOWN).setPackage(FOCUS_OWNER_PACKAGE), + Intent(ACTION_LOCKDOWN) + .setPackage(FOCUS_OWNER_PACKAGE) + .putExtra(EXTRA_UNTIL, until.toString()), PERMISSION_SIGNAL, ) } - private fun isStickDay(isoDate: String): Boolean { - val format = SimpleDateFormat("yyyy-MM-dd", Locale.US) - val parsed = runCatching { format.parse(isoDate) }.getOrNull() ?: return false - val calendar = Calendar.getInstance() - calendar.time = parsed - return calendar.get(Calendar.DAY_OF_WEEK) in STICK_DAYS + /** Clears any lockdown now (the escape hatch). */ + fun sendLift(context: Context) { + Log.i(TAG, "signalling focus-owner lift") + context.sendBroadcast( + Intent(ACTION_LIFT).setPackage(FOCUS_OWNER_PACKAGE), + PERMISSION_SIGNAL, + ) } } diff --git a/phone_app/lib/main.dart b/phone_app/lib/main.dart index 2977e6b..2d9e216 100644 --- a/phone_app/lib/main.dart +++ b/phone_app/lib/main.dart @@ -5,6 +5,7 @@ import 'package:wake_alarm_sync/screens/ring_screen.dart'; import 'package:wake_alarm_sync/screens/session_screen.dart'; import 'package:wake_alarm_sync/services/alarm_policy.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/headless_ring_start.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; /// Route the Kotlin `AlarmActivity` hands to the engine as its initial @@ -29,6 +30,14 @@ Future main() async { runApp(const WakeAlarmSyncApp()); } +/// Headless entry point: the daily alarm starts it natively +/// (`HeadlessRingStart.kt`) to publish `ring_started` with no screen. +/// +/// Lives here because only the root library's entry points are reachable +/// by name from Kotlin; `vm:entry-point` keeps release tree-shaking off it. +@pragma('vm:entry-point') +Future ringStartedMain() => runHeadlessRingStart(); + /// Root application widget. class WakeAlarmSyncApp extends StatelessWidget { /// Creates a [WakeAlarmSyncApp]; [newScheduler] is the probes' seam. diff --git a/phone_app/lib/screens/escape_dialog.dart b/phone_app/lib/screens/escape_dialog.dart new file mode 100644 index 0000000..dcaf92e --- /dev/null +++ b/phone_app/lib/screens/escape_dialog.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/services/stakes.dart'; + +/// Asks why this morning should be excused; the reason, or null if +/// cancelled. +/// +/// The confirm button stays disabled until the reason has at least +/// [escapeReasonMinChars] characters: the hatch exists for bugs, and a typed +/// explanation is what the incident report is read against. +Future showEscapeDialog(BuildContext context) => + showDialog(context: context, builder: (_) => const EscapeDialog()); + +/// The escape-hatch dialog; see [showEscapeDialog]. +class EscapeDialog extends StatefulWidget { + /// Creates the dialog. + const EscapeDialog({super.key}); + + @override + State createState() => _EscapeDialogState(); +} + +class _EscapeDialogState extends State { + final TextEditingController _reason = TextEditingController(); + + @override + void dispose() { + _reason.dispose(); + super.dispose(); + } + + int get _length => _reason.text.trim().length; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final ready = _length >= escapeReasonMinChars; + return AlertDialog( + // The keyboard covers half a small screen; scroll instead of overflow. + scrollable: true, + title: const Text('That was a bug'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Lifts the app lockdown, clears the streak, cancels today’s PC ' + 'penalty and stops a ringing PC. Every use is listed in the ' + 'incident report.', + ), + const SizedBox(height: 16), + TextField( + controller: _reason, + autofocus: true, + maxLines: 4, + minLines: 2, + decoration: const InputDecoration( + labelText: 'What went wrong?', + border: OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + Text( + '$_length / $escapeReasonMinChars characters', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: ready + ? () => Navigator.of(context).pop(_reason.text.trim()) + : null, + child: const Text('Excuse this morning'), + ), + ], + ); + } +} diff --git a/phone_app/lib/screens/home_body.dart b/phone_app/lib/screens/home_body.dart index a8f26b3..7b7709e 100644 --- a/phone_app/lib/screens/home_body.dart +++ b/phone_app/lib/screens/home_body.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/screens/home_history_card.dart'; import 'package:wake_alarm_sync/screens/home_presence_line.dart'; +import 'package:wake_alarm_sync/screens/stakes_card.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; /// The home screen's body: the armed time, the earlier-only control and the @@ -20,6 +21,7 @@ class HomeBody extends StatelessWidget { required this.configured, required this.onPickEarlier, this.syncEpoch = 0, + this.onEscaped, super.key, }); @@ -49,6 +51,9 @@ class HomeBody extends StatelessWidget { /// until the next cold start. final int syncEpoch; + /// Called after the escape hatch was used, so the screen publishes it. + final VoidCallback? onEscaped; + @override Widget build(BuildContext context) { return SafeArea( @@ -109,6 +114,14 @@ class HomeBody extends StatelessWidget { ), ), const SizedBox(height: 24), + // A third distinct key in this Column, for the same reason as + // the history card's below. + StakesCard( + key: ValueKey('stakes-$syncEpoch'), + busy: busy, + onEscaped: onEscaped, + ), + const SizedBox(height: 24), // Not `ValueKey(syncEpoch)`: the presence line above // already carries that key in this Column, and two children // with one key mis-reconcile silently in release -- the old diff --git a/phone_app/lib/screens/home_screen.dart b/phone_app/lib/screens/home_screen.dart index 088aaa2..6735171 100644 --- a/phone_app/lib/screens/home_screen.dart +++ b/phone_app/lib/screens/home_screen.dart @@ -236,6 +236,7 @@ class _HomeScreenState extends State { configured: _configured, syncEpoch: _syncEpoch, onPickEarlier: () => unawaited(_pickEarlier()), + onEscaped: () => unawaited(_syncToPc()), ), ); } diff --git a/phone_app/lib/screens/ring_body.dart b/phone_app/lib/screens/ring_body.dart index 839f300..5513a60 100644 --- a/phone_app/lib/screens/ring_body.dart +++ b/phone_app/lib/screens/ring_body.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/screens/ring_challenge.dart'; import 'package:wake_alarm_sync/screens/ring_checklist.dart'; +import 'package:wake_alarm_sync/screens/ring_stakes_line.dart'; import 'package:wake_alarm_sync/services/challenge.dart'; import 'package:wake_alarm_sync/services/dismiss_gate.dart'; @@ -50,7 +51,9 @@ class RingBody extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Wake up', style: theme.textTheme.headlineMedium), - const SizedBox(height: 24), + const SizedBox(height: 16), + const RingStakesLine(), + const SizedBox(height: 8), if (inChallenges) RingChallenge( challenge: challenge, diff --git a/phone_app/lib/screens/ring_challenge.dart b/phone_app/lib/screens/ring_challenge.dart index 3193ca2..0d173ad 100644 --- a/phone_app/lib/screens/ring_challenge.dart +++ b/phone_app/lib/screens/ring_challenge.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/services/challenge.dart'; +import 'package:wake_alarm_sync/services/dismiss_gate.dart'; /// The question half of the ring screen, shown once the walk is proven. class RingChallenge extends StatelessWidget { @@ -61,3 +62,17 @@ class RingChallenge extends StatelessWidget { ); } } + +/// Logs [challenge] so a hardware test can be scripted. +/// +/// Debug aid only: the answer is already on screen for whoever is holding +/// the phone, so this leaks nothing the user cannot see. `debugPrint` +/// rather than `developer.log` so it reaches logcat, which is what the +/// emulator harness reads the answer from. +void logChallenge(Challenge challenge, DismissGate gate) { + debugPrint( + 'WakeAlarmRing: challenge kind=${challenge.kind.name} ' + 'display="${challenge.display}" answer=${challenge.answer} ' + '${gate.challengesPassed}/${gate.challengesRequired}', + ); +} diff --git a/phone_app/lib/screens/ring_screen.dart b/phone_app/lib/screens/ring_screen.dart index 25c040f..d42c362 100644 --- a/phone_app/lib/screens/ring_screen.dart +++ b/phone_app/lib/screens/ring_screen.dart @@ -3,12 +3,14 @@ import 'dart:developer' as developer; import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/screens/ring_body.dart'; +import 'package:wake_alarm_sync/screens/ring_challenge.dart'; import 'package:wake_alarm_sync/services/alarm_policy.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/challenge.dart'; import 'package:wake_alarm_sync/services/dismiss_gate.dart'; import 'package:wake_alarm_sync/services/home_presence.dart'; import 'package:wake_alarm_sync/services/ramp_progress.dart'; +import 'package:wake_alarm_sync/services/ring_start_recorder.dart'; import 'package:wake_alarm_sync/services/ring_startup.dart'; import 'package:wake_alarm_sync/services/room_proof.dart'; import 'package:wake_alarm_sync/services/session_handoff.dart'; @@ -32,6 +34,7 @@ class RingScreen extends StatefulWidget { this.clock, this.presence, this.handoff, + this.ringStart, }); /// Platform bridge; defaults to the real MethodChannel. @@ -46,6 +49,9 @@ class RingScreen extends StatefulWidget { /// Starts or resumes the morning session; defaults to the real handoff. final SessionHandoff? handoff; + /// Publishes `ring_started` for the PC; defaults to the real recorder. + final RingStartRecorder? ringStart; + /// Time since the ring started; defaults to a real `Stopwatch`. /// /// Injectable because `Stopwatch` reads the wall clock, which @@ -80,6 +86,9 @@ class _RingScreenState extends State { void initState() { super.initState(); unawaited(_scheduler.beginStepCount()); + // Fire-and-forget, never throws: the PC's fallback alarm waits for it, + // the ring must not. + unawaited((widget.ringStart ?? RingStartRecorder()).record()); unawaited(_loadRoomProof()); // The escape hatch. Set up before anything can fail, so a crash in the // settings load cannot leave an alarm with no way out. @@ -158,19 +167,7 @@ class _RingScreenState extends State { if (_gate.stage == DismissStage.challenges) _logChallenge(); } - /// Logs the current challenge so a hardware test can be scripted. - /// - /// Debug aid only: the answer is already on screen for whoever is holding - /// the phone, so this leaks nothing the user cannot see. `debugPrint` - /// rather than `developer.log` so it reaches logcat, which is what the - /// emulator harness reads the answer from. - void _logChallenge() { - debugPrint( - 'WakeAlarmRing: challenge kind=${_challenge.kind.name} ' - 'display="${_challenge.display}" answer=${_challenge.answer} ' - '${_gate.challengesPassed}/${_gate.challengesRequired}', - ); - } + void _logChallenge() => logChallenge(_challenge, _gate); @override void dispose() { diff --git a/phone_app/lib/screens/ring_stakes_line.dart b/phone_app/lib/screens/ring_stakes_line.dart new file mode 100644 index 0000000..16b56f7 --- /dev/null +++ b/phone_app/lib/screens/ring_stakes_line.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/services/enforcement_bridge.dart'; +import 'package:wake_alarm_sync/services/today_stakes.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; + +/// The ring screen's one line: what not dismissing costs today. +/// +/// Read once from the native stick when the ring opens, so the time and the +/// lockdown date are the ones the native check will actually use. Absent on +/// a day with no stick-day consequence, and whenever the platform answers +/// nothing -- it must never be what breaks a ring. +class RingStakesLine extends StatefulWidget { + /// Creates the line; [bridge] is injectable for tests. + const RingStakesLine({super.key, this.bridge}); + + /// Native stick reader; defaults to the real channel. + final EnforcementBridge? bridge; + + @override + State createState() => _RingStakesLineState(); +} + +class _RingStakesLineState extends State { + String? _line; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + String? line; + try { + final state = await (widget.bridge ?? EnforcementBridge()).state(); + line = state == null ? null : ringStakesLine(state); + } on Exception { + line = null; + } + if (!mounted || line == null) return; + setState(() => _line = line); + } + + @override + Widget build(BuildContext context) { + final line = _line; + if (line == null) return const SizedBox.shrink(); + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + line, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + // Null-safe: a missing theme extension must not break a ring. + color: theme.extension()?.warning, + ), + ), + ); + } +} diff --git a/phone_app/lib/screens/stakes_card.dart b/phone_app/lib/screens/stakes_card.dart new file mode 100644 index 0000000..2463976 --- /dev/null +++ b/phone_app/lib/screens/stakes_card.dart @@ -0,0 +1,162 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/screens/escape_dialog.dart'; +import 'package:wake_alarm_sync/services/alarm_policy.dart'; +import 'package:wake_alarm_sync/services/enforcement_bridge.dart'; +import 'package:wake_alarm_sync/services/enforcement_state.dart'; +import 'package:wake_alarm_sync/services/escape_hatch.dart'; +import 'package:wake_alarm_sync/services/today_stakes.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; + +/// Today's stakes in plain words, anything already in force, the streak, +/// and the escape hatch while it lasts. +/// +/// Self-loading from the native stick, keyed by the home screen's sync +/// epoch like the history card. Absent when the native side answers +/// nothing (a widget test, the desktop) rather than showing made-up stakes. +class StakesCard extends StatefulWidget { + /// Creates the card; every dependency is injectable for tests. + const StakesCard({ + super.key, + this.bridge, + this.hatch, + this.now, + this.nextAlarm, + this.onEscaped, + this.busy = false, + }); + + /// Native stick reader; defaults to the real channel. + final EnforcementBridge? bridge; + + /// The escape hatch; defaults to the real one. + final EscapeHatch? hatch; + + /// The clock; defaults to `DateTime.now`. + final DateTime Function()? now; + + /// Tomorrow's alarm time; defaults to [AlarmPolicy.load]. + final Future Function()? nextAlarm; + + /// Called after a successful escape, so the screen can publish it. + final VoidCallback? onEscaped; + + /// Disables the escape while the screen is syncing. + final bool busy; + + @override + State createState() => _StakesCardState(); +} + +class _StakesCardState extends State { + late final EnforcementBridge _bridge = widget.bridge ?? EnforcementBridge(); + late final EscapeHatch _hatch = + widget.hatch ?? EscapeHatch(bridge: _bridge, now: widget.now); + EnforcementState? _state; + AlarmTime? _next; + String _message = ''; + + DateTime _now() => (widget.now ?? DateTime.now)(); + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + EnforcementState? state; + AlarmTime? next; + try { + state = await _bridge.state(); + next = await (widget.nextAlarm ?? () => _policyAlarm(_now()))(); + } on Exception { + // No native side: show nothing rather than invented stakes. + } + if (!mounted) return; + setState(() { + _state = state; + _next = next; + }); + } + + /// Tomorrow's alarm as the policy will arm it (ramp + any one-shot + /// override). Resolved from tomorrow's midnight: "the next alarm after + /// now" would still be today's before it rings. + static Future _policyAlarm(DateTime now) async { + final midnight = DateTime(now.year, now.month, now.day + 1); + final policy = await AlarmPolicy.load(now: midnight); + return (hour: policy.hour, minute: policy.minute); + } + + Future _escape() async { + final reason = await showEscapeDialog(context); + if (reason == null) return; + final ok = await _hatch.use(reason); + if (!mounted) return; + setState( + () => _message = ok + ? 'Excused — lockdown lifted, publishing to the PC.' + : 'The escape hatch refused (closed, or the reason is too short).', + ); + await _load(); + if (ok) widget.onEscaped?.call(); + } + + @override + Widget build(BuildContext context) { + final state = _state; + if (state == null) return const SizedBox.shrink(); + final theme = Theme.of(context); + final warning = theme.extension()?.warning; + final view = describeStakes(now: _now(), state: state, nextAlarm: _next); + final body = theme.textTheme.bodyMedium; + return Card( + color: theme.colorScheme.surfaceContainerHigh, + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(view.title, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + for (final line in view.stakes) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text(line, style: body), + ), + for (final line in view.pending) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text(line, style: body?.copyWith(color: warning)), + ), + const SizedBox(height: 8), + Text( + view.streak, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (_message.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(_message, style: body), + ], + if (_hatch.isOpen) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: widget.busy ? null : () => unawaited(_escape()), + icon: const Icon(Icons.bug_report_outlined), + label: const Text('That was a bug'), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/phone_app/lib/services/enforcement_bridge.dart b/phone_app/lib/services/enforcement_bridge.dart new file mode 100644 index 0000000..e69dc6a --- /dev/null +++ b/phone_app/lib/services/enforcement_bridge.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/enforcement_state.dart'; + +/// Dart side of the wake-enforcement methods (`EnforcementChannel.kt`). +/// +/// The same single alarm channel as [AlarmScheduler] -- a separate class +/// only so that file stays under the line cap and this one stays about one +/// thing: reading the native stick and the event log, and the escape. +class EnforcementBridge { + /// Creates a bridge; [channel] is injectable so tests need no platform. + EnforcementBridge({MethodChannel? channel}) + : _channel = channel ?? const MethodChannel(AlarmScheduler.channelName); + + final MethodChannel _channel; + + /// Appends a Dart-side event to the native log, stamped natively. + Future appendEvent( + String kind, [ + Map data = const {}, + ]) => _channel.invokeMethod('appendEvent', { + 'kind': kind, + 'data': data, + }); + + /// The whole event log, newest first; empty when the platform has none. + Future>> readEvents() async { + final text = await _channel.invokeMethod('readEvents'); + if (text == null) return const >[]; + final decoded = jsonDecode(text); + if (decoded is! List) return const >[]; + return >[ + for (final entry in decoded) + if (entry is Map) Map.from(entry), + ]; + } + + /// The native stick; null when the platform answers nothing usable. + Future state() async => EnforcementState.fromMap( + await _channel.invokeMapMethod('enforcementState'), + ); + + /// Marks today escaped natively, clears the streak and lifts the + /// lockdown; false when the hatch has closed. + Future escapeToday() async => + await _channel.invokeMethod('escapeToday') ?? false; +} diff --git a/phone_app/lib/services/enforcement_state.dart b/phone_app/lib/services/enforcement_state.dart new file mode 100644 index 0000000..53431a5 --- /dev/null +++ b/phone_app/lib/services/enforcement_state.dart @@ -0,0 +1,103 @@ +import 'package:wake_alarm_sync/services/iso_time.dart'; + +/// The native stick, as the stakes card and the ring line read it. +/// +/// Parsed from `EnforcementChannel.kt`'s `enforcementState` map. Every value +/// is decided natively (`StickCheck.kt`); Dart only displays it, so the date +/// on screen is the date a miss would actually lock through. +class EnforcementState { + /// Creates a state; every field mirrors one key of the native map. + const EnforcementState({ + required this.today, + required this.dismissedToday, + required this.escapedToday, + required this.freeToday, + required this.freeTomorrow, + required this.streak, + this.alarmHour, + this.alarmMinute, + this.lockedThrough, + this.lastMiss, + this.updatedAt, + this.lockIfMissToday, + this.lockIfMissTomorrow, + }); + + /// Parses the native map; null when it is absent or malformed. + static EnforcementState? fromMap(Map? map) { + if (map == null) return null; + final today = _date(map['today']); + if (today == null) return null; + return EnforcementState( + today: today, + alarmHour: map['alarmHour'] as int?, + alarmMinute: map['alarmMinute'] as int?, + dismissedToday: map['dismissedToday'] == true, + escapedToday: map['escapedToday'] == true, + freeToday: map['freeToday'] == true, + freeTomorrow: map['freeTomorrow'] == true, + streak: (map['streak'] as int?) ?? 0, + lockedThrough: _date(map['lockedThrough']), + lastMiss: _date(map['lastMiss']), + updatedAt: map['updatedAt'] as String?, + lockIfMissToday: _date(map['lockIfMissToday']), + lockIfMissTomorrow: _date(map['lockIfMissTomorrow']), + ); + } + + /// The phone's local date when this was read. + final DateTime today; + + /// The stored daily alarm `A`, or null when none is armed. + final int? alarmHour; + + /// See [alarmHour]. + final int? alarmMinute; + + /// Whether today's ring was dismissed. + final bool dismissedToday; + + /// Whether today was excused through the escape hatch. + final bool escapedToday; + + /// Whether today is a free day in the native cache. + final bool freeToday; + + /// Whether tomorrow is a free day in the native cache. + final bool freeTomorrow; + + /// Consecutive stick-day misses. + final int streak; + + /// The current lockdown's last day, or null when none was sent. + final DateTime? lockedThrough; + + /// The last stick day that was missed. + final DateTime? lastMiss; + + /// ISO instant of the last change to the stick, or null before any. + final String? updatedAt; + + /// What a miss today would lock through. + final DateTime? lockIfMissToday; + + /// What a miss tomorrow would lock through, given the state as it is. + final DateTime? lockIfMissTomorrow; + + /// The `stick_state` field's value (`DOCS-wake-enforcement.md`). + Map toStickStateJson() => { + 'streak': streak, + 'locked_through': _iso(lockedThrough), + 'last_miss': _iso(lastMiss), + }; + + static DateTime? _date(Object? value) { + if (value is! String) return null; + final parsed = DateTime.tryParse(value); + return parsed == null + ? null + : DateTime(parsed.year, parsed.month, parsed.day); + } + + static String? _iso(DateTime? day) => day == null ? null : isoDate(day); +} diff --git a/phone_app/lib/services/enforcement_sync.dart b/phone_app/lib/services/enforcement_sync.dart new file mode 100644 index 0000000..4ebfd55 --- /dev/null +++ b/phone_app/lib/services/enforcement_sync.dart @@ -0,0 +1,109 @@ +/// The wake-enforcement fields on the synced alarm record +/// (`DOCS-wake-enforcement.md`, "Firebase -- fields on the phone's +/// alarm.json"). +/// +/// Staged on every sync through the same [stageField] pattern as the +/// session data, each under a stable stamp (the newest entry's own `at`, or +/// the stick's `updatedAt`) so an unchanged value is never re-pushed. The PC +/// alarm parser ignores every field it does not name. +library; + +import 'dart:developer' as developer; + +import 'package:wake_alarm_sync/services/alarm_sync_service.dart'; +import 'package:wake_alarm_sync/services/enforcement_bridge.dart'; +import 'package:wake_alarm_sync/services/escape_log.dart'; +import 'package:wake_alarm_sync/services/ring_started_store.dart'; +import 'package:wake_alarm_sync/services/settings_sync.dart'; + +/// `{"date", "at"}` of today's first ring-screen open. +const ringStartedField = 'ring_started'; + +/// Escape-hatch uses, newest first, at most [EscapeLog.carried]. +const escapesField = 'escapes'; + +/// `{"streak", "locked_through", "last_miss"}`. +const stickStateField = 'stick_state'; + +/// The phone event log, newest first. +const phoneEventsField = 'phone_events'; + +/// At most this many events ride on the record... +const eventsCarried = 400; + +/// ...and none older than this. +const eventsMaxAge = Duration(days: 14); + +/// Stages every enforcement field newer than the record's copy. +/// +/// The Dart-held records go first and need no platform; the native ones +/// (stick, event log) are skipped when the channel is absent, as under a +/// widget test or the desktop, rather than failing the whole sync. +Future stageEnforcementData( + AlarmSyncService service, { + EnforcementBridge? bridge, + DateTime Function()? now, +}) async { + final ring = await RingStartedStore.load(); + final ringAt = ring?['at']; + if (ring != null && ringAt is String) { + await stageField(service, ringStartedField, ringAt, ring, configured: true); + } + final escapes = await EscapeLog.load(); + final escapeAt = escapes.firstOrNull?['at']; + if (escapeAt is String) { + await stageField( + service, + escapesField, + escapeAt, + escapes, + configured: true, + ); + } + final native = bridge ?? EnforcementBridge(); + try { + final state = await native.state(); + final stamp = state?.updatedAt; + if (state != null && stamp != null) { + final json = state.toStickStateJson(); + await stageField(service, stickStateField, stamp, json, configured: true); + } + final events = boundEvents( + await native.readEvents(), + (now ?? DateTime.now)(), + ); + final eventAt = events.firstOrNull?['at']; + if (eventAt is String) { + await stageField( + service, + phoneEventsField, + eventAt, + events, + configured: true, + ); + } + } on Object catch (e) { + // No native side here: MissingPluginException under a widget test, a + // FlutterError with no binding at all under a plain unit test. Either + // way the sync itself must go on. + developer.log('enforcement fields skipped: $e', name: 'WakeAlarmSync'); + } +} + +/// [events] cut to the contract's bounds: newest first, at most +/// [eventsCarried], none older than [eventsMaxAge] before [now]. An entry +/// whose `at` does not parse is dropped -- it could never be placed on a +/// timeline anyway. +List> boundEvents( + List> events, + DateTime now, +) { + final cutoff = now.subtract(eventsMaxAge); + final dated = <(DateTime, Map)>[ + for (final event in events) + if (DateTime.tryParse('${event['at']}') case final at? + when at.isAfter(cutoff)) + (at, event), + ]..sort((a, b) => b.$1.compareTo(a.$1)); + return [for (final (_, event) in dated.take(eventsCarried)) event]; +} diff --git a/phone_app/lib/services/escape_hatch.dart b/phone_app/lib/services/escape_hatch.dart new file mode 100644 index 0000000..4b7d6d8 --- /dev/null +++ b/phone_app/lib/services/escape_hatch.dart @@ -0,0 +1,33 @@ +import 'package:wake_alarm_sync/services/enforcement_bridge.dart'; +import 'package:wake_alarm_sync/services/escape_log.dart'; +import 'package:wake_alarm_sync/services/stakes.dart'; + +/// "That was a bug": excuses this morning, on both halves. +/// +/// Native first (`EscapeHatch.kt`): today marked escaped, streak cleared, +/// focus-owner told to lift, `escape_used` + `lift_sent` logged. Then the +/// entry goes into [EscapeLog], which the next sync publishes as `escapes` +/// -- that is what stops a ringing PC and cancels today's PC penalty. The +/// caller triggers that sync. +class EscapeHatch { + /// Creates the hatch; [bridge] and [now] are injectable for tests. + EscapeHatch({EnforcementBridge? bridge, DateTime Function()? now}) + : _bridge = bridge ?? EnforcementBridge(), + _now = now ?? DateTime.now; + + final EnforcementBridge _bridge; + final DateTime Function() _now; + + /// Whether the hatch is open today. + bool get isOpen => escapeHatchOpen(_now()); + + /// Uses the hatch with [reason]; false when it is closed, the reason is + /// too short, or the native side refused. Nothing is recorded then. + Future use(String reason) async { + final trimmed = reason.trim(); + if (!isOpen || trimmed.length < escapeReasonMinChars) return false; + if (!await _bridge.escapeToday()) return false; + await EscapeLog.add(trimmed, now: _now()); + return true; + } +} diff --git a/phone_app/lib/services/escape_log.dart b/phone_app/lib/services/escape_log.dart new file mode 100644 index 0000000..085f653 --- /dev/null +++ b/phone_app/lib/services/escape_log.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; +import 'package:wake_alarm_sync/services/iso_time.dart'; + +/// Every use of the "That was a bug" escape hatch, newest first. +/// +/// Published as the `escapes` field (`DOCS-wake-enforcement.md`): the PC +/// stops its fallback ring and drops today's penalty when it sees one, and +/// the incident report lists every use at the top. Kept locally so a failed +/// publish is retried by the next sync instead of being lost. +abstract final class EscapeLog { + /// SharedPreferences key holding the JSON list. + static const key = 'enforcement.escapes'; + + /// How many entries the record carries. + static const carried = 30; + + /// The stored entries, newest first; malformed storage reads as none. + static Future>> load() async { + final prefs = await SharedPreferences.getInstance(); + final text = prefs.getString(key); + if (text == null) return >[]; + try { + final decoded = jsonDecode(text); + if (decoded is! List) return >[]; + return >[ + for (final entry in decoded) + if (entry is Map) Map.from(entry), + ]; + } on FormatException { + return >[]; + } + } + + /// Records an escape of [now]'s morning with [reason]; returns the entry. + static Future> add( + String reason, { + DateTime? now, + }) async { + final at = now ?? DateTime.now(); + final entry = { + 'id': const Uuid().v4(), + 'date': isoDate(at), + 'at': isoWithOffset(at), + 'reason': reason, + }; + final entries = [entry, ...await load()].take(carried).toList(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(key, jsonEncode(entries)); + return entry; + } +} diff --git a/phone_app/lib/services/headless_ring_start.dart b/phone_app/lib/services/headless_ring_start.dart new file mode 100644 index 0000000..8d5215c --- /dev/null +++ b/phone_app/lib/services/headless_ring_start.dart @@ -0,0 +1,36 @@ +import 'dart:developer' as developer; +import 'dart:ui' show DartPluginRegistrant; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:wake_alarm_sync/services/ring_start_recorder.dart'; + +/// Channel `HeadlessRingStart.kt` listens on for `done`. +const headlessChannelName = 'com.kuhy.wake_alarm_sync/headless'; + +/// The headless engine's whole job: publish `ring_started`, then say done. +/// +/// Started natively when the daily alarm fires (`ringStartedMain` in +/// `main.dart`), so the PC learns the phone is ringing even when the ring +/// screen never opens. `done` is sent whatever happened -- the native side +/// would otherwise keep a second engine alive until its timeout. +Future runHeadlessRingStart({ + RingStartRecorder? recorder, + MethodChannel? channel, +}) async { + WidgetsFlutterBinding.ensureInitialized(); + // A non-`main` entrypoint does not register Dart-side plugin + // implementations (shared_preferences, secure storage) by itself. + DartPluginRegistrant.ensureInitialized(); + try { + await (recorder ?? RingStartRecorder(openedEvent: null, source: 'alarm')) + .record(); + } finally { + try { + await (channel ?? const MethodChannel(headlessChannelName)) + .invokeMethod('done'); + } on Object catch (e) { + developer.log('headless done not delivered: $e', name: 'WakeAlarmRing'); + } + } +} diff --git a/phone_app/lib/services/iso_time.dart b/phone_app/lib/services/iso_time.dart new file mode 100644 index 0000000..4623bf3 --- /dev/null +++ b/phone_app/lib/services/iso_time.dart @@ -0,0 +1,25 @@ +/// ISO-8601 with the local UTC offset, the shape the enforcement contract's +/// `at` fields use (`2026-10-01T07:15:03+02:00`). +/// +/// `DateTime.toIso8601String()` on a local time has no offset at all, which +/// the PC would read as its own local time -- right by coincidence at home, +/// wrong by hours on any phone that travelled. +library; + +/// [when] as `yyyy-MM-ddTHH:mm:ss±hh:mm`, seconds precision. +String isoWithOffset(DateTime when) { + final local = when.toLocal(); + final offset = local.timeZoneOffset; + final sign = offset.isNegative ? '-' : '+'; + final minutes = offset.inMinutes.abs(); + return '${_date(local)}T${_two(local.hour)}:${_two(local.minute)}:' + '${_two(local.second)}$sign${_two(minutes ~/ 60)}:${_two(minutes % 60)}'; +} + +/// `yyyy-MM-dd` of [day]'s local calendar date. +String isoDate(DateTime day) => _date(day); + +String _date(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${_two(d.month)}-${_two(d.day)}'; + +String _two(int n) => n.toString().padLeft(2, '0'); diff --git a/phone_app/lib/services/ring_start_recorder.dart b/phone_app/lib/services/ring_start_recorder.dart new file mode 100644 index 0000000..a72029e --- /dev/null +++ b/phone_app/lib/services/ring_start_recorder.dart @@ -0,0 +1,83 @@ +import 'dart:developer' as developer; + +import 'package:wake_alarm_sync/services/alarm_sync_service.dart'; +import 'package:wake_alarm_sync/services/enforcement_bridge.dart'; +import 'package:wake_alarm_sync/services/enforcement_sync.dart'; +import 'package:wake_alarm_sync/services/firebase_backend.dart'; +import 'package:wake_alarm_sync/services/ring_started_store.dart'; +import 'package:wake_alarm_sync/services/sync_settings.dart'; + +/// Tells the PC the phone is ringing. +/// +/// The PC's fallback alarm rings when no `ring_started` for today has +/// arrived by A + 2 min, so this publishes straight away instead of waiting +/// for the next sync. Two sources run it: the headless engine the alarm +/// starts natively (`HeadlessRingStart.kt`, [source] `alarm`) and the ring +/// screen (`ring_screen`) -- the screen does not always open. The first +/// ring of the day wins, and once it has reached the backend the other +/// source does not push again. It never throws and nothing waits on it: a +/// slow or broken network must never stand between the user and the +/// dismiss sequence. +class RingStartRecorder { + /// Creates a recorder; every dependency is injectable for tests. + RingStartRecorder({ + EnforcementBridge? bridge, + Future Function()? open, + Future Function(AlarmSyncService service, String token)? push, + DateTime Function()? now, + this.openedEvent = 'ring_screen_opened', + this.source = 'ring_screen', + }) : _bridge = bridge ?? EnforcementBridge(), + _open = open ?? AlarmSyncService.open, + _push = push ?? ((service, token) => service.push(token)), + _now = now ?? DateTime.now; + + final EnforcementBridge _bridge; + final Future Function() _open; + final Future Function(AlarmSyncService, String) _push; + final DateTime Function() _now; + + /// Event logged before anything else, or null for none (headless). + final String? openedEvent; + + /// Which source published, in `ring_started_published`'s data. + final String source; + + /// Logs the open, records today's start, and publishes it. Never throws. + /// + /// Returns whether the publish reached the backend, for tests. + Future record() async { + final opened = openedEvent; + if (opened != null) { + try { + await _bridge.appendEvent(opened); + } on Object catch (e) { + _log('$opened not logged: $e'); + } + } + try { + final entry = await RingStartedStore.recordFirst(_now()); + if (await RingStartedStore.publishedOn(entry['date'])) return true; + final settings = await SyncSettings.load(); + final hasFirebase = await loadAccount() != null; + if (!settings.isConfigured && !hasFirebase) return false; + final service = await _open(); + // Only the enforcement fields: the ring is no time to restage tags + // and history, and the push carries the whole log regardless. + await stageEnforcementData(service, bridge: _bridge, now: _now); + await _push(service, settings.token); + final date = '${entry['date']}'; + await RingStartedStore.markPublished(date); + await _bridge.appendEvent('ring_started_published', { + 'date': date, + 'source': source, + }); + return true; + } on Object catch (e) { + _log('ring_started not published: $e'); + return false; + } + } + + void _log(String message) => developer.log(message, name: 'WakeAlarmRing'); +} diff --git a/phone_app/lib/services/ring_started_store.dart b/phone_app/lib/services/ring_started_store.dart new file mode 100644 index 0000000..f96f227 --- /dev/null +++ b/phone_app/lib/services/ring_started_store.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wake_alarm_sync/services/iso_time.dart'; + +/// Today's `ring_started` record: `{"date", "at"}`, set when the ring screen +/// first opens. +/// +/// This is the ping the PC's fallback alarm waits for: no `ring_started` by +/// A + 2 min means the phone is silent, and the PC rings. Kept locally so a +/// publish that failed during the ring is retried by every later sync. +abstract final class RingStartedStore { + /// SharedPreferences key holding the JSON record. + static const key = 'enforcement.ringStarted'; + + /// SharedPreferences key holding the date whose record reached the PC. + static const publishedKey = 'enforcement.ringStartedPublished'; + + /// The stored record, or null when none or malformed. + /// + /// Reloaded first: the headless engine the alarm starts and the ring + /// screen's engine each cache preferences, and a stale cache would let + /// the second overwrite the first ring of the day. + static Future?> load() async { + final prefs = await _fresh(); + final text = prefs.getString(key); + if (text == null) return null; + try { + final decoded = jsonDecode(text); + return decoded is Map ? Map.from(decoded) : null; + } on FormatException { + return null; + } + } + + /// Records [now] as today's ring start, unless today already has one. + /// + /// The first of the day wins: a re-ring or a probe reopens the ring + /// screen, and moving the stamp later would hide when the phone actually + /// started ringing. + static Future> recordFirst(DateTime now) async { + final existing = await load(); + if (existing != null && existing['date'] == isoDate(now)) return existing; + final entry = { + 'date': isoDate(now), + 'at': isoWithOffset(now), + }; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(key, jsonEncode(entry)); + return entry; + } + + /// Whether [date]'s record has already reached the backend. + static Future publishedOn(Object? date) async => + (await _fresh()).getString(publishedKey) == date; + + /// Records that [date]'s record reached the backend. + static Future markPublished(String date) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(publishedKey, date); + } + + static Future _fresh() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + return prefs; + } +} diff --git a/phone_app/lib/services/settings_sync.dart b/phone_app/lib/services/settings_sync.dart index 4a4a55b..7028a8a 100644 --- a/phone_app/lib/services/settings_sync.dart +++ b/phone_app/lib/services/settings_sync.dart @@ -19,6 +19,7 @@ library; import 'package:crdt_sync/crdt_sync.dart'; import 'package:wake_alarm_sync/services/alarm_sync_service.dart'; +import 'package:wake_alarm_sync/services/enforcement_sync.dart'; import 'package:wake_alarm_sync/services/home_location_settings.dart'; import 'package:wake_alarm_sync/services/ramp_progress.dart'; import 'package:wake_alarm_sync/services/room_proof_settings.dart'; @@ -71,6 +72,7 @@ Future stageLocalSettings(AlarmSyncService service) async { ); await _stageEarned(service); await stageSessionData(service); + await stageEnforcementData(service); } /// Pushes the union of the local and logged earned dates when it is new. diff --git a/phone_app/lib/services/stakes.dart b/phone_app/lib/services/stakes.dart new file mode 100644 index 0000000..d533d52 --- /dev/null +++ b/phone_app/lib/services/stakes.dart @@ -0,0 +1,49 @@ +/// The wake-enforcement numbers the app shows, in one place. +/// +/// The single Dart source for every number on the stakes card and the ring +/// line (`DOCS-wake-enforcement.md`). Each one mirrors a Python constant in +/// `wake_alarm/_constants.py`, and `wake_alarm/tests/test_stakes_mirrors.py` +/// parses this file and fails when they drift -- keep each value a plain +/// literal on its own line. +library; + +/// A time of day, as the Python side's `(hour, minute)` tuples. +typedef ClockTime = ({int hour, int minute}); + +/// No dismiss this long after the alarm is a miss (`SESSION_NONE_GRACE`). +const stickGrace = Duration(minutes: 15); + +/// A started morning is exempt this long (`SESSION_PROVISIONAL_GRACE`). +const provisionalGrace = Duration(minutes: 90); + +/// ...and never past this time of day (`SESSION_PROVISIONAL_CAP`). +const ClockTime provisionalCap = (hour: 9, minute: 30); + +/// A morning finished in time keeps the PC open until this time of day +/// (`SESSION_COMPLETED_UNTIL`). +const ClockTime completedUntil = (hour: 11, minute: 0); + +/// The PC rings when no ring started this long after the alarm +/// (`FALLBACK_PHONE_SILENT_AFTER`). +const pcPhoneSilentAfter = Duration(minutes: 2); + +/// The PC's fallback ring stops after this long (`FALLBACK_RING_CAP`). +const pcRingCap = Duration(minutes: 30); + +/// A lockdown spans at most this many calendar days, today included +/// (`STICK_LOCKDOWN_MAX_DAYS`). +const stickLockdownMaxDays = 5; + +/// The escape hatch's last usable day, inclusive (`ESCAPE_HATCH_LAST_DAY`). +final escapeHatchLastDay = DateTime(2026, 10, 15); + +/// An escape needs a typed reason at least this long +/// (`ESCAPE_REASON_MIN_CHARS`). +const escapeReasonMinChars = 40; + +/// Whether the escape hatch is still open on [now]'s calendar day. +/// +/// Date-only and inclusive: [escapeHatchLastDay] is midnight, so comparing +/// instants would close the hatch at 00:00:01 on its last day. +bool escapeHatchOpen(DateTime now) => + !DateTime(now.year, now.month, now.day).isAfter(escapeHatchLastDay); diff --git a/phone_app/lib/services/today_stakes.dart b/phone_app/lib/services/today_stakes.dart new file mode 100644 index 0000000..71a091e --- /dev/null +++ b/phone_app/lib/services/today_stakes.dart @@ -0,0 +1,166 @@ +import 'package:wake_alarm_sync/services/alarm_ramp.dart'; +import 'package:wake_alarm_sync/services/enforcement_state.dart'; +import 'package:wake_alarm_sync/services/stakes.dart'; + +/// An alarm time of day. +typedef AlarmTime = ClockTime; + +/// What the stakes card says, already in words. +class StakesView { + /// Creates a view. + const StakesView({ + required this.title, + required this.stakes, + required this.pending, + required this.streak, + }); + + /// "Today · Thu 2 Oct — stick day". + final String title; + + /// What the coming morning costs or earns. + final List stakes; + + /// A lockdown, penalty or escape already in force. + final List pending; + + /// The streak line. + final String streak; +} + +/// The stakes of the next undecided morning, from the native [state]. +/// +/// That is today while today's alarm is still open (not dismissed, not +/// excused, not past A + [stickGrace]), tomorrow after -- in the evening, +/// today's stakes are history. Today uses the stored alarm `A`, the one the +/// native check judges by; tomorrow uses [nextAlarm] (the policy's next +/// time), falling back to `A`. Every number comes from `stakes.dart`. +StakesView describeStakes({ + required DateTime now, + required EnforcementState state, + AlarmTime? nextAlarm, +}) { + final today = state.today; + final stored = _stored(state); + final todayOpen = + stored != null && + !state.dismissedToday && + !state.escapedToday && + now.isBefore(_at(today, stored).add(stickGrace)); + final day = todayOpen ? today : _plusDays(today, 1); + final alarm = todayOpen ? stored : (nextAlarm ?? stored); + final free = todayOpen ? state.freeToday : state.freeTomorrow; + final when = todayOpen ? 'Today' : 'Tomorrow'; + final stick = isWorkday(day); + final kind = free ? 'free day' : (stick ? 'stick day' : 'carrot day'); + final lines = []; + if (alarm == null) { + lines.add('No alarm armed yet — nothing at stake.'); + } else if (free) { + lines.add('Free day — no alarm, nothing at stake.'); + } else { + final ringAt = _at(day, alarm); + if (stick) { + final lock = + (todayOpen ? state.lockIfMissToday : state.lockIfMissTomorrow) ?? + _plusDays(day, 1); + final next = todayOpen + ? "tomorrow's" + : "${_weekday(_plusDays(day, 1))}'s"; + lines.add( + 'Miss (no dismiss by ${hhmm(ringAt.add(stickGrace))}) → apps ' + 'locked through ${formatDay(lock)} + $next PC leniency gone', + ); + } else { + lines.add( + 'Finish the morning by ${hhmm(carrotCutoff(ringAt))} → PC open ' + 'until ${_hhmm(completedUntil.hour, completedUntil.minute)}', + ); + } + lines.add( + 'PC rings at ${hhmm(ringAt.add(pcPhoneSilentAfter))} if the phone is ' + 'silent (up to ${pcRingCap.inMinutes} min)', + ); + } + return StakesView( + title: '$when · ${formatDay(day)} — $kind', + stakes: lines, + pending: _pending(state), + streak: + 'Streak: ${state.streak} missed stick ' + 'day${state.streak == 1 ? '' : 's'} in a row', + ); +} + +/// The ring screen's one line: the stick-day consequence of not dismissing, +/// or null when this ring has none (not a stick day, free, already +/// dismissed, excused or missed). +String? ringStakesLine(EnforcementState state) { + final stored = _stored(state); + if (stored == null || !isWorkday(state.today) || state.freeToday) { + return null; + } + if (state.dismissedToday || state.escapedToday) return null; + if (state.lastMiss == state.today) return null; + final by = _at(state.today, stored).add(stickGrace); + final lock = state.lockIfMissToday ?? _plusDays(state.today, 1); + return 'Stick day: no dismiss by ${hhmm(by)} → apps locked through ' + '${formatDay(lock)}'; +} + +/// The carrot's cutoff for a morning whose alarm rings at [ringAt]: +/// `min(A + provisionalGrace, provisionalCap)`, the PC's own rule. +DateTime carrotCutoff(DateTime ringAt) { + final cap = DateTime( + ringAt.year, + ringAt.month, + ringAt.day, + provisionalCap.hour, + provisionalCap.minute, + ); + final relative = ringAt.add(provisionalGrace); + return relative.isBefore(cap) ? relative : cap; +} + +List _pending(EnforcementState state) { + final today = state.today; + final locked = state.lockedThrough; + return [ + if (state.escapedToday) 'This morning is excused ("That was a bug").', + if (locked != null && !locked.isBefore(today) && !state.escapedToday) + 'Apps locked through ${formatDay(locked)}.', + if (state.lastMiss == today && !state.escapedToday) + "Missed this morning → tomorrow's PC leniency is gone.", + ]; +} + +AlarmTime? _stored(EnforcementState state) { + final hour = state.alarmHour; + final minute = state.alarmMinute; + return hour == null || minute == null ? null : (hour: hour, minute: minute); +} + +// Built from fields, never day + Duration: DST would shift the wall clock. +DateTime _at(DateTime day, AlarmTime t) => + DateTime(day.year, day.month, day.day, t.hour, t.minute); + +DateTime _plusDays(DateTime day, int n) => + DateTime(day.year, day.month, day.day + n); + +/// `HH:MM` of [t]. +String hhmm(DateTime t) => _hhmm(t.hour, t.minute); + +String _hhmm(int h, int m) => + '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}'; + +const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const _months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', // + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', +]; + +String _weekday(DateTime day) => _weekdays[day.weekday - 1]; + +/// "Fri 3 Oct". +String formatDay(DateTime day) => + '${_weekday(day)} ${day.day} ${_months[day.month - 1]}'; diff --git a/phone_app/pubspec.lock b/phone_app/pubspec.lock index d110668..11a9c90 100644 --- a/phone_app/pubspec.lock +++ b/phone_app/pubspec.lock @@ -110,10 +110,10 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + sha256: b7cc2390aac296c083bea762859413eb1cf5b630c70e0c7e4d831018f762a8b2 url: "https://pub.dev" source: hosted - version: "1.0.9" + version: "2.0.0" fake_async: dependency: transitive description: diff --git a/phone_app/pubspec.yaml b/phone_app/pubspec.yaml index 3e0808c..3e371d9 100644 --- a/phone_app/pubspec.yaml +++ b/phone_app/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: url: https://github.com/kuhyx/utils ref: crdt_sync_dart-v0.11.1 path: crdt_sync_dart - cupertino_icons: 1.0.9 + cupertino_icons: 2.0.0 flutter: sdk: flutter flutter_secure_storage: 11.2.0 diff --git a/phone_app/test/fake_enforcement_channel.dart b/phone_app/test/fake_enforcement_channel.dart new file mode 100644 index 0000000..fa1d8ab --- /dev/null +++ b/phone_app/test/fake_enforcement_channel.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/iso_time.dart'; + +/// Stands in for `EnforcementChannel.kt` on the alarm channel: an event log +/// in memory, a canned stick state and a canned escape answer. Every other +/// method answers null, like the ring tests' own mocks. +class FakeEnforcementChannel { + /// Every call received, in order. + final List calls = []; + + /// The native event log, newest first. + final List> events = >[]; + + /// What `enforcementState` returns. + Map? state; + + /// What `escapeToday` returns. + bool escapeResult = true; + + /// The clock stamping appended events. + DateTime Function() now = DateTime.now; + + /// The method names received, in order. + List get methods => calls.map((c) => c.method).toList(); + + /// The alarm channel this fake answers on. + MethodChannel get channel => const MethodChannel(AlarmScheduler.channelName); + + /// Installs the fake for the current test and removes it afterwards. + void install() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + ..setMockMethodCallHandler(channel, _handle); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + } + + Future _handle(MethodCall call) async { + calls.add(call); + final args = call.arguments; + switch (call.method) { + case 'appendEvent': + if (args is Map) { + events.insert(0, { + 'at': isoWithOffset(now()), + 'kind': args['kind'], + 'data': args['data'], + }); + } + return null; + case 'readEvents': + return jsonEncode(events); + case 'enforcementState': + return state; + case 'escapeToday': + return escapeResult; + } + return null; + } +} + +/// A native `enforcementState` map; Thursday 2026-10-01, alarm 07:00. +Map nativeState({ + String today = '2026-10-01', + int? alarmHour = 7, + int? alarmMinute = 0, + bool dismissedToday = false, + bool escapedToday = false, + bool freeToday = false, + bool freeTomorrow = false, + int streak = 0, + String? lockedThrough, + String? lastMiss, + String? updatedAt, + String? lockIfMissToday = '2026-10-02', + String? lockIfMissTomorrow = '2026-10-03', +}) => { + 'today': today, + 'alarmHour': alarmHour, + 'alarmMinute': alarmMinute, + 'dismissedToday': dismissedToday, + 'escapedToday': escapedToday, + 'freeToday': freeToday, + 'freeTomorrow': freeTomorrow, + 'streak': streak, + 'lockedThrough': lockedThrough, + 'lastMiss': lastMiss, + 'updatedAt': updatedAt, + 'lockIfMissToday': lockIfMissToday, + 'lockIfMissTomorrow': lockIfMissTomorrow, +}; diff --git a/phone_app/test/ring_stakes_test.dart b/phone_app/test/ring_stakes_test.dart new file mode 100644 index 0000000..22b48c3 --- /dev/null +++ b/phone_app/test/ring_stakes_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wake_alarm_sync/screens/ring_screen.dart'; +import 'package:wake_alarm_sync/screens/ring_stakes_line.dart'; +import 'package:wake_alarm_sync/services/ring_start_recorder.dart'; + +import 'fake_enforcement_channel.dart'; + +/// Counts calls instead of touching storage or the network. +class _CountingRecorder extends RingStartRecorder { + int calls = 0; + + @override + Future record() async { + calls++; + return true; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakeEnforcementChannel native; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + native = FakeEnforcementChannel()..install(); + }); + + Future pumpLine(WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: RingStakesLine())), + ); + await tester.pump(); + } + + testWidgets('a stick-day ring shows what not dismissing costs', ( + tester, + ) async { + native.state = nativeState(); + await pumpLine(tester); + expect( + find.text( + 'Stick day: no dismiss by 07:15 → apps locked through Fri 2 Oct', + ), + findsOneWidget, + ); + }); + + testWidgets('nothing once today was dismissed', (tester) async { + native.state = nativeState(dismissedToday: true); + await pumpLine(tester); + expect(find.byType(Text), findsNothing); + }); + + testWidgets('nothing when the platform answers nothing', (tester) async { + await pumpLine(tester); + expect(find.byType(Text), findsNothing); + }); + + testWidgets('nothing, and no crash, with no channel at all', (tester) async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(native.channel, null); + await pumpLine(tester); + expect(find.byType(Text), findsNothing); + }); + + testWidgets('opening the ring screen records the ring start once', ( + tester, + ) async { + native.state = nativeState(); + final recorder = _CountingRecorder(); + await tester.pumpWidget(MaterialApp(home: RingScreen(ringStart: recorder))); + await tester.pump(); + expect(recorder.calls, 1); + expect(find.textContaining('Stick day:'), findsOneWidget); + // The ring screen's own timers (bypass, step poll) outlive the test. + await tester.pumpWidget(const SizedBox.shrink()); + }); +} diff --git a/phone_app/test/services/enforcement_bridge_test.dart b/phone_app/test/services/enforcement_bridge_test.dart new file mode 100644 index 0000000..f871802 --- /dev/null +++ b/phone_app/test/services/enforcement_bridge_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/enforcement_bridge.dart'; +import 'package:wake_alarm_sync/services/enforcement_state.dart'; + +import '../fake_enforcement_channel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakeEnforcementChannel native; + final bridge = EnforcementBridge(); + + setUp(() { + native = FakeEnforcementChannel()..install(); + }); + + test('appendEvent passes kind and data; readEvents decodes them', () async { + await bridge.appendEvent('ring_screen_opened'); + await bridge.appendEvent('ring_started_published', {'date': '2026-10-01'}); + expect(native.calls.first.arguments, { + 'kind': 'ring_screen_opened', + 'data': {}, + }); + final events = await bridge.readEvents(); + expect(events.map((e) => e['kind']), [ + 'ring_started_published', + 'ring_screen_opened', + ]); + expect(events.first['data'], {'date': '2026-10-01'}); + }); + + Future answerReadEvents(Object? reply) async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(AlarmScheduler.channelName), + (call) async => reply, + ); + } + + test( + 'readEvents tolerates no answer, a non-list and stray entries', + () async { + await answerReadEvents(null); + expect(await bridge.readEvents(), isEmpty); + await answerReadEvents('{"not": "a list"}'); + expect(await bridge.readEvents(), isEmpty); + await answerReadEvents('[1, {"kind": "boot"}]'); + expect(await bridge.readEvents(), [ + {'kind': 'boot'}, + ]); + }, + ); + + test('state parses the native map; no answer is null', () async { + expect(await bridge.state(), isNull); + native.state = nativeState(streak: 2, lockedThrough: '2026-10-03'); + final state = await bridge.state(); + expect(state?.streak, 2); + expect(state?.lockedThrough, DateTime(2026, 10, 3)); + }); + + test('escapeToday returns the native answer, false when silent', () async { + expect(await bridge.escapeToday(), isTrue); + native.escapeResult = false; + expect(await bridge.escapeToday(), isFalse); + await answerReadEvents(null); + expect(await bridge.escapeToday(), isFalse); + }); + + group('EnforcementState.fromMap', () { + test('null, or a map without a date, is null', () { + expect(EnforcementState.fromMap(null), isNull); + expect(EnforcementState.fromMap({'streak': 1}), isNull); + expect(EnforcementState.fromMap({'today': 'garbage'}), isNull); + }); + + test('reads every field; missing ones take safe defaults', () { + final full = EnforcementState.fromMap( + nativeState( + dismissedToday: true, + escapedToday: true, + freeToday: true, + freeTomorrow: true, + streak: 3, + lockedThrough: '2026-10-04', + lastMiss: '2026-10-01', + updatedAt: '2026-10-01T07:15:05+02:00', + ), + )!; + expect(full.today, DateTime(2026, 10)); + expect((full.alarmHour, full.alarmMinute), (7, 0)); + expect(full.dismissedToday && full.escapedToday, isTrue); + expect(full.freeToday && full.freeTomorrow, isTrue); + expect(full.lastMiss, DateTime(2026, 10)); + expect(full.updatedAt, '2026-10-01T07:15:05+02:00'); + expect(full.lockIfMissToday, DateTime(2026, 10, 2)); + expect(full.lockIfMissTomorrow, DateTime(2026, 10, 3)); + + final bare = EnforcementState.fromMap({'today': '2026-10-01'})!; + expect(bare.streak, 0); + expect(bare.alarmHour, isNull); + expect(bare.dismissedToday, isFalse); + expect(bare.lockedThrough, isNull); + }); + + test('toStickStateJson is the contract shape', () { + final state = EnforcementState.fromMap( + nativeState( + streak: 1, + lockedThrough: '2026-10-02', + lastMiss: '2026-10-01', + ), + )!; + expect(state.toStickStateJson(), { + 'streak': 1, + 'locked_through': '2026-10-02', + 'last_miss': '2026-10-01', + }); + final clean = EnforcementState.fromMap(nativeState())!; + expect(clean.toStickStateJson(), { + 'streak': 0, + 'locked_through': null, + 'last_miss': null, + }); + }); + }); +} diff --git a/phone_app/test/services/enforcement_sync_test.dart b/phone_app/test/services/enforcement_sync_test.dart new file mode 100644 index 0000000..fa41579 --- /dev/null +++ b/phone_app/test/services/enforcement_sync_test.dart @@ -0,0 +1,220 @@ +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wake_alarm_sync/services/alarm_sync_service.dart'; +import 'package:wake_alarm_sync/services/enforcement_sync.dart'; +import 'package:wake_alarm_sync/services/escape_log.dart'; +import 'package:wake_alarm_sync/services/iso_time.dart'; +import 'package:wake_alarm_sync/services/ring_start_recorder.dart'; +import 'package:wake_alarm_sync/services/ring_started_store.dart'; +import 'package:wake_alarm_sync/services/settings_sync.dart'; + +import '../fake_enforcement_channel.dart'; +import '../fake_secure_storage.dart'; + +class _MemPersistence implements LogPersistence { + String? text; + + @override + Future read() async => text; + + @override + Future write(String value) async => text = value; +} + +Future _open() => + AlarmSyncService.open(persistence: _MemPersistence(), deviceId: 'me'); + +Map _event(DateTime at, String kind) => { + 'at': isoWithOffset(at), + 'kind': kind, + 'data': {}, +}; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final now = DateTime(2026, 10, 1, 7, 20); + late FakeEnforcementChannel native; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + installFakeSecureStorage(); + native = FakeEnforcementChannel() + ..now = (() => now) + ..install(); + }); + + group('stageEnforcementData', () { + test('nothing recorded anywhere stages nothing', () async { + native.state = nativeState(); + final service = await _open(); + await stageEnforcementData(service, now: () => now); + for (final field in [ + ringStartedField, + escapesField, + stickStateField, + phoneEventsField, + ]) { + expect(service.field(field), isNull, reason: field); + } + }); + + test('every field lands, stamped by its own newest entry', () async { + final ring = await RingStartedStore.recordFirst(DateTime(2026, 10, 1, 7)); + await EscapeLog.add('a reason', now: DateTime(2026, 10, 1, 7, 10)); + native + ..state = nativeState( + streak: 1, + lockedThrough: '2026-10-02', + lastMiss: '2026-10-01', + updatedAt: '2026-10-01T07:15:05+02:00', + ) + ..events.add(_event(DateTime(2026, 10, 1, 7, 15), 'lockdown_sent')); + final service = await _open(); + + await stageLocalSettings(service); + + expect(service.field(ringStartedField)?.$1, ring); + final escapes = service.field(escapesField)!; + expect((escapes.$1! as List).single, containsPair('reason', 'a reason')); + expect( + escapes.$2.wallTimeMs, + DateTime(2026, 10, 1, 7, 10).millisecondsSinceEpoch, + ); + expect(service.field(stickStateField)?.$1, { + 'streak': 1, + 'locked_through': '2026-10-02', + 'last_miss': '2026-10-01', + }); + final events = service.field(phoneEventsField)!.$1! as List; + expect(events.single, containsPair('kind', 'lockdown_sent')); + }); + + test('restaging an unchanged log pushes nothing new', () async { + native.events.add(_event(DateTime(2026, 10, 1, 7), 'boot')); + final service = await _open(); + await stageEnforcementData(service, now: () => now); + final before = service.field(phoneEventsField)!.$2; + await stageEnforcementData(service, now: () => now); + expect(service.field(phoneEventsField)!.$2, before); + }); + + test('a stick never changed (no updatedAt) is not staged', () async { + native.state = nativeState(); + final service = await _open(); + await stageEnforcementData(service, now: () => now); + expect(service.field(stickStateField), isNull); + }); + }); + + test('no native side still stages the Dart-held records', () async { + // Replaces the fake with no handler at all. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(native.channel, null); + await EscapeLog.add('offline', now: now); + final service = await _open(); + await stageEnforcementData(service, now: () => now); + expect(service.field(escapesField), isNotNull); + expect(service.field(phoneEventsField), isNull); + }); + + group('boundEvents', () { + test('newest first, at most 400, none older than 14 days', () { + final events = [ + _event(now.subtract(const Duration(days: 15)), 'stale'), + {'at': 'garbage', 'kind': 'unplaced'}, + for (var i = 0; i < eventsCarried + 5; i++) + _event(now.subtract(Duration(minutes: i)), 'e$i'), + ]..shuffle(); + final bounded = boundEvents(events, now); + expect(bounded, hasLength(eventsCarried)); + expect(bounded.first['kind'], 'e0'); + expect(bounded.map((e) => e['kind']), isNot(contains('stale'))); + expect(bounded.map((e) => e['kind']), isNot(contains('unplaced'))); + }); + }); + + group('RingStartRecorder', () { + test('unconfigured: logs the open and keeps the start locally', () async { + final recorder = RingStartRecorder(now: () => now); + expect(await recorder.record(), isFalse); + expect(native.events.single['kind'], 'ring_screen_opened'); + expect((await RingStartedStore.load())?['date'], '2026-10-01'); + }); + + test('configured: stages ring_started, pushes, logs it', () async { + SharedPreferences.setMockInitialValues({'github_token': 'ghp_x'}); + final service = await _open(); + final pushed = []; + final recorder = RingStartRecorder( + open: () async => service, + push: (s, token) async => pushed.add(token), + now: () => now, + ); + expect(await recorder.record(), isTrue); + expect(pushed, ['ghp_x']); + expect( + service.field(ringStartedField)?.$1, + containsPair('at', isNotNull), + ); + expect(native.events.first['kind'], 'ring_started_published'); + expect(native.events.first['data'], { + 'date': '2026-10-01', + 'source': 'ring_screen', + }); + }); + + test( + 'the headless source logs no open; a later screen skips the push', + () async { + SharedPreferences.setMockInitialValues({'github_token': 'ghp_x'}); + final service = await _open(); + final pushed = []; + Future push(AlarmSyncService _, String token) async => + pushed.add(token); + final headless = RingStartRecorder( + open: () async => service, + push: push, + now: () => now, + openedEvent: null, + source: 'alarm', + ); + expect(await headless.record(), isTrue); + expect(native.events.single['kind'], 'ring_started_published'); + expect(native.events.single['data'], { + 'date': '2026-10-01', + 'source': 'alarm', + }); + final screen = RingStartRecorder( + open: () async => service, + push: push, + now: () => now.add(const Duration(minutes: 1)), + ); + expect(await screen.record(), isTrue); + expect(pushed, hasLength(1)); + expect(native.events.first['kind'], 'ring_screen_opened'); + // The first ring of the day is the one on record. + expect((await RingStartedStore.load())?['at'], isoWithOffset(now)); + }, + ); + + test('a failing push is swallowed and not logged as published', () async { + SharedPreferences.setMockInitialValues({'github_token': 'ghp_x'}); + final recorder = RingStartRecorder( + open: _open, + push: (_, _) async => throw Exception('offline'), + now: () => now, + ); + expect(await recorder.record(), isFalse); + expect(native.events.map((e) => e['kind']), ['ring_screen_opened']); + }); + + test('no channel at all still records and never throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(native.channel, null); + expect(await RingStartRecorder(now: () => now).record(), isFalse); + expect(await RingStartedStore.load(), isNotNull); + }); + }); +} diff --git a/phone_app/test/services/escape_records_test.dart b/phone_app/test/services/escape_records_test.dart new file mode 100644 index 0000000..9ad08fe --- /dev/null +++ b/phone_app/test/services/escape_records_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wake_alarm_sync/services/escape_hatch.dart'; +import 'package:wake_alarm_sync/services/escape_log.dart'; +import 'package:wake_alarm_sync/services/iso_time.dart'; +import 'package:wake_alarm_sync/services/ring_started_store.dart'; +import 'package:wake_alarm_sync/services/stakes.dart'; + +import '../fake_enforcement_channel.dart'; + +final String _reason = 'x' * escapeReasonMinChars; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + group('isoWithOffset / escapeHatchOpen', () { + test('carries the local offset and round-trips to the same instant', () { + final when = DateTime(2026, 10, 1, 7, 2, 9); + final iso = isoWithOffset(when); + expect(iso, matches(RegExp(r'^2026-10-01T07:02:09[+-]\d\d:\d\d$'))); + expect(DateTime.parse(iso).isAtSameMomentAs(when), isTrue); + expect(isoDate(DateTime(987, 3, 4)), '0987-03-04'); + }); + + test('the hatch is open through its last day, inclusive', () { + expect(escapeHatchOpen(DateTime(2026, 10, 15, 23, 59)), isTrue); + expect(escapeHatchOpen(DateTime(2026, 10, 16)), isFalse); + }); + }); + + group('EscapeLog', () { + test('adds newest first, with the contract fields', () async { + final first = await EscapeLog.add('first', now: DateTime(2026, 10, 1, 7)); + await EscapeLog.add('second', now: DateTime(2026, 10, 2, 7)); + final entries = await EscapeLog.load(); + expect(entries.map((e) => e['reason']), ['second', 'first']); + expect(first.keys, containsAll(['id', 'date', 'at', 'reason'])); + expect(first['date'], '2026-10-01'); + expect(first['id'], isNot(entries.first['id'])); + }); + + test('keeps at most 30', () async { + for (var i = 0; i < EscapeLog.carried + 3; i++) { + await EscapeLog.add('r$i', now: DateTime(2026, 10, 1, 7, i)); + } + final entries = await EscapeLog.load(); + expect(entries, hasLength(EscapeLog.carried)); + expect(entries.first['reason'], 'r${EscapeLog.carried + 2}'); + }); + + test('malformed storage reads as no escapes', () async { + SharedPreferences.setMockInitialValues({EscapeLog.key: 'not json'}); + expect(await EscapeLog.load(), isEmpty); + SharedPreferences.setMockInitialValues({EscapeLog.key: '{"a": 1}'}); + expect(await EscapeLog.load(), isEmpty); + }); + }); + + group('RingStartedStore', () { + test('the first open of the day wins; a new day replaces it', () async { + final first = await RingStartedStore.recordFirst( + DateTime(2026, 10, 1, 7), + ); + final again = await RingStartedStore.recordFirst( + DateTime(2026, 10, 1, 7, 30), + ); + expect(again, first); + expect(first['at'], isoWithOffset(DateTime(2026, 10, 1, 7))); + final next = await RingStartedStore.recordFirst(DateTime(2026, 10, 2, 7)); + expect(next['date'], '2026-10-02'); + expect(await RingStartedStore.load(), next); + }); + + test('malformed storage reads as none', () async { + SharedPreferences.setMockInitialValues({RingStartedStore.key: '[1'}); + expect(await RingStartedStore.load(), isNull); + SharedPreferences.setMockInitialValues({RingStartedStore.key: '[1]'}); + expect(await RingStartedStore.load(), isNull); + }); + }); + + group('EscapeHatch', () { + late FakeEnforcementChannel native; + setUp(() => native = FakeEnforcementChannel()..install()); + + EscapeHatch hatch(DateTime now) => EscapeHatch(now: () => now); + + test('a long enough reason escapes natively, then logs it', () async { + final ok = await hatch(DateTime(2026, 10, 1, 7)).use(' $_reason '); + expect(ok, isTrue); + expect(native.methods, ['escapeToday']); + final entry = (await EscapeLog.load()).single; + expect(entry['reason'], _reason); + expect(entry['date'], '2026-10-01'); + }); + + test('a short reason is refused before anything happens', () async { + final short = 'x' * (escapeReasonMinChars - 1); + expect(await hatch(DateTime(2026, 10)).use(short), isFalse); + expect(native.calls, isEmpty); + }); + + test('after the last day it is closed', () async { + final closed = hatch(DateTime(2026, 10, 16)); + expect(closed.isOpen, isFalse); + expect(await closed.use(_reason), isFalse); + expect(native.calls, isEmpty); + }); + + test('a native refusal records nothing', () async { + native.escapeResult = false; + expect(await hatch(DateTime(2026, 10)).use(_reason), isFalse); + expect(await EscapeLog.load(), isEmpty); + }); + }); +} diff --git a/phone_app/test/services/headless_ring_start_test.dart b/phone_app/test/services/headless_ring_start_test.dart new file mode 100644 index 0000000..1f18563 --- /dev/null +++ b/phone_app/test/services/headless_ring_start_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/services/headless_ring_start.dart'; +import 'package:wake_alarm_sync/services/ring_start_recorder.dart'; + +/// Records calls; optionally throws, to prove `done` is still sent. +class _Recorder extends RingStartRecorder { + _Recorder({this.fail = false}); + + final bool fail; + int calls = 0; + + @override + Future record() async { + calls++; + if (fail) throw StateError('boom'); + return true; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel(headlessChannelName); + late List calls; + + setUp(() { + calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + }); + + test('records once, then tells the native side it is done', () async { + final recorder = _Recorder(); + await runHeadlessRingStart(recorder: recorder); + expect(recorder.calls, 1); + expect(calls, ['done']); + }); + + test('done is sent even when recording blows up', () async { + await expectLater( + runHeadlessRingStart(recorder: _Recorder(fail: true)), + throwsStateError, + ); + expect(calls, ['done']); + }); + + test('an unanswered done is swallowed', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + await runHeadlessRingStart(recorder: _Recorder()); + }); +} diff --git a/phone_app/test/services/today_stakes_test.dart b/phone_app/test/services/today_stakes_test.dart new file mode 100644 index 0000000..6c13b6c --- /dev/null +++ b/phone_app/test/services/today_stakes_test.dart @@ -0,0 +1,191 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/services/enforcement_state.dart'; +import 'package:wake_alarm_sync/services/today_stakes.dart'; + +import '../fake_enforcement_channel.dart'; + +EnforcementState _state({ + String today = '2026-10-01', + int? alarmHour = 7, + int? alarmMinute = 0, + bool dismissedToday = false, + bool escapedToday = false, + bool freeToday = false, + bool freeTomorrow = false, + int streak = 0, + String? lockedThrough, + String? lastMiss, + String? lockIfMissToday = '2026-10-02', + String? lockIfMissTomorrow = '2026-10-03', +}) => EnforcementState.fromMap( + nativeState( + today: today, + alarmHour: alarmHour, + alarmMinute: alarmMinute, + dismissedToday: dismissedToday, + escapedToday: escapedToday, + freeToday: freeToday, + freeTomorrow: freeTomorrow, + streak: streak, + lockedThrough: lockedThrough, + lastMiss: lastMiss, + lockIfMissToday: lockIfMissToday, + lockIfMissTomorrow: lockIfMissTomorrow, + ), +)!; + +void main() { + // 2026-10-01 is a Thursday (stick day), 2026-10-02 a Friday (carrot day). + final thuMorning = DateTime(2026, 10, 1, 6, 50); + + group('describeStakes', () { + test('a stick day before A + 15 states the miss and the PC ring', () { + final view = describeStakes(now: thuMorning, state: _state()); + expect(view.title, 'Today · Thu 1 Oct — stick day'); + const miss = + 'Miss (no dismiss by 07:15) → apps locked through Fri 2 Oct + ' + "tomorrow's PC leniency gone"; + expect(view.stakes, [ + miss, + 'PC rings at 07:02 if the phone is silent (up to 30 min)', + ]); + expect(view.pending, isEmpty); + expect(view.streak, 'Streak: 0 missed stick days in a row'); + }); + + test('a dismissed morning moves on to tomorrow, with its own time', () { + final view = describeStakes( + now: DateTime(2026, 10, 1, 7, 5), + state: _state(dismissedToday: true), + nextAlarm: (hour: 6, minute: 59), + ); + expect(view.title, 'Tomorrow · Fri 2 Oct — carrot day'); + expect(view.stakes, [ + 'Finish the morning by 08:29 → PC open until 11:00', + 'PC rings at 07:01 if the phone is silent (up to 30 min)', + ]); + }); + + test('past A + 15 is tomorrow too, even without a dismiss', () { + final view = describeStakes( + now: DateTime(2026, 9, 30, 20), + state: _state(today: '2026-09-30'), + ); + // Wednesday evening: Thursday is a stick day, judged by the next day. + expect(view.title, 'Tomorrow · Thu 1 Oct — stick day'); + expect(view.stakes.first, contains('locked through Sat 3 Oct')); + expect(view.stakes.first, contains("Fri's PC leniency gone")); + }); + + test('an escaped morning is no longer open', () { + final view = describeStakes( + now: thuMorning, + state: _state(escapedToday: true), + ); + expect(view.title, startsWith('Tomorrow')); + expect(view.pending, ['This morning is excused ("That was a bug").']); + }); + + test('a free day has nothing at stake', () { + final view = describeStakes( + now: thuMorning, + state: _state(freeToday: true), + ); + expect(view.title, 'Today · Thu 1 Oct — free day'); + expect(view.stakes, ['Free day — no alarm, nothing at stake.']); + }); + + test('tomorrow being free is read from the tomorrow flag', () { + final view = describeStakes( + now: DateTime(2026, 10, 1, 20), + state: _state(freeTomorrow: true), + ); + expect(view.title, 'Tomorrow · Fri 2 Oct — free day'); + }); + + test('no alarm at all says so instead of inventing a time', () { + final view = describeStakes( + now: thuMorning, + state: _state(alarmHour: null, alarmMinute: null), + ); + expect(view.title, startsWith('Tomorrow')); + expect(view.stakes, ['No alarm armed yet — nothing at stake.']); + }); + + test('a missing native lock date falls back to the next day', () { + final view = describeStakes( + now: thuMorning, + state: _state(lockIfMissToday: null), + ); + expect(view.stakes.first, contains('locked through Fri 2 Oct')); + }); + + test('a lockdown in force and a miss today are pending', () { + final view = describeStakes( + now: DateTime(2026, 10, 1, 9), + state: _state( + streak: 2, + lockedThrough: '2026-10-03', + lastMiss: '2026-10-01', + ), + ); + expect(view.pending, [ + 'Apps locked through Sat 3 Oct.', + "Missed this morning → tomorrow's PC leniency is gone.", + ]); + expect(view.streak, 'Streak: 2 missed stick days in a row'); + }); + + test('a lockdown that has ended is not pending; one miss is singular', () { + final view = describeStakes( + now: DateTime(2026, 10, 1, 9), + state: _state(streak: 1, lockedThrough: '2026-09-30'), + ); + expect(view.pending, isEmpty); + expect(view.streak, 'Streak: 1 missed stick day in a row'); + }); + }); + + group('ringStakesLine', () { + test('an open stick-day ring names the cost of not dismissing', () { + expect( + ringStakesLine(_state()), + 'Stick day: no dismiss by 07:15 → apps locked through Fri 2 Oct', + ); + }); + + test('a missing native lock date falls back to the next day', () { + expect( + ringStakesLine(_state(lockIfMissToday: null)), + contains('Fri 2 Oct'), + ); + }); + + test('nothing on a carrot, free, settled or alarm-less day', () { + expect(ringStakesLine(_state(today: '2026-10-02')), isNull); + expect(ringStakesLine(_state(freeToday: true)), isNull); + expect(ringStakesLine(_state(dismissedToday: true)), isNull); + expect(ringStakesLine(_state(escapedToday: true)), isNull); + expect(ringStakesLine(_state(lastMiss: '2026-10-01')), isNull); + expect(ringStakesLine(_state(alarmHour: null)), isNull); + }); + }); + + group('carrotCutoff', () { + test('is A + 90 min before the cap, the cap after it', () { + expect( + carrotCutoff(DateTime(2026, 10, 2, 7)), + DateTime(2026, 10, 2, 8, 30), + ); + expect( + carrotCutoff(DateTime(2026, 10, 2, 8, 15)), + DateTime(2026, 10, 2, 9, 30), + ); + }); + }); + + test('formatDay and hhmm', () { + expect(formatDay(DateTime(2026, 12, 31)), 'Thu 31 Dec'); + expect(hhmm(DateTime(2026, 1, 1, 6, 5)), '06:05'); + }); +} diff --git a/phone_app/test/stakes_card_test.dart b/phone_app/test/stakes_card_test.dart new file mode 100644 index 0000000..6a9168e --- /dev/null +++ b/phone_app/test/stakes_card_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wake_alarm_sync/screens/home_body.dart'; +import 'package:wake_alarm_sync/screens/stakes_card.dart'; +import 'package:wake_alarm_sync/services/escape_log.dart'; +import 'package:wake_alarm_sync/services/stakes.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; + +import 'fake_enforcement_channel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakeEnforcementChannel native; + final thursday = DateTime(2026, 10, 1, 6, 50); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + native = FakeEnforcementChannel()..install(); + }); + + Future pumpCard( + WidgetTester tester, { + DateTime? now, + bool busy = false, + }) async { + var escaped = 0; + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: SingleChildScrollView( + child: StakesCard( + now: () => now ?? thursday, + nextAlarm: () async => (hour: 6, minute: 59), + onEscaped: () => escaped++, + busy: busy, + ), + ), + ), + ), + ); + await tester.pump(); + return escaped; + } + + testWidgets('states the stick day, what is pending and the streak', ( + tester, + ) async { + native.state = nativeState( + streak: 1, + lockedThrough: '2026-10-02', + lastMiss: '2026-09-30', + ); + await pumpCard(tester); + expect(find.text('Today · Thu 1 Oct — stick day'), findsOneWidget); + expect(find.textContaining('no dismiss by 07:15'), findsOneWidget); + expect(find.textContaining('PC rings at 07:02'), findsOneWidget); + expect(find.text('Apps locked through Fri 2 Oct.'), findsOneWidget); + expect(find.text('Streak: 1 missed stick day in a row'), findsOneWidget); + expect(find.text('That was a bug'), findsOneWidget); + }); + + testWidgets('absent when the native side answers nothing', (tester) async { + await pumpCard(tester); + expect(find.byType(Card), findsNothing); + }); + + testWidgets('absent, not broken, with no channel at all', (tester) async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(native.channel, null); + await pumpCard(tester); + expect(find.byType(Card), findsNothing); + }); + + testWidgets('tomorrow defaults to the alarm policy', (tester) async { + native.state = nativeState(dismissedToday: true); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: StakesCard(now: () => thursday)), + ), + ); + await tester.pump(); + // Friday's ramp with only the seed minutes earned: 07:57. + expect(find.textContaining('Finish the morning by 09:27'), findsOneWidget); + expect(find.textContaining('PC rings at 07:59'), findsOneWidget); + }); + + testWidgets('the escape button is gone after the last day', (tester) async { + native.state = nativeState(today: '2026-10-16'); + await pumpCard(tester, now: DateTime(2026, 10, 16, 6)); + expect(find.text('That was a bug'), findsNothing); + }); + + testWidgets('the escape button is disabled while syncing', (tester) async { + native.state = nativeState(); + await pumpCard(tester, busy: true); + final button = tester.widget( + find.ancestor( + of: find.text('That was a bug'), + matching: find.byWidgetPredicate((w) => w is TextButton), + ), + ); + expect(button.onPressed, isNull); + }); + + testWidgets('escaping needs a long reason, then lifts and publishes', ( + tester, + ) async { + native.state = nativeState(); + var escaped = 0; + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: StakesCard( + now: () => thursday, + nextAlarm: () async => null, + onEscaped: () => escaped++, + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('That was a bug')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + FilledButton confirm() => + tester.widget(find.byType(FilledButton)); + expect(confirm().onPressed, isNull); + await tester.enterText(find.byType(TextField), 'too short'); + await tester.pump(); + expect(confirm().onPressed, isNull); + expect(find.text('9 / $escapeReasonMinChars characters'), findsOneWidget); + + const reason = 'The tag on the bathroom wall fell off overnight.'; + await tester.enterText(find.byType(TextField), reason); + await tester.pump(); + expect(confirm().onPressed, isNotNull); + await tester.tap(find.text('Excuse this morning')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(native.methods, contains('escapeToday')); + expect((await EscapeLog.load()).single['reason'], reason); + expect(escaped, 1); + expect(find.textContaining('Excused'), findsOneWidget); + }); + + testWidgets('cancelling the dialog changes nothing', (tester) async { + native.state = nativeState(); + final escaped = await pumpCard(tester); + await tester.tap(find.text('That was a bug')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Cancel')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(native.methods, isNot(contains('escapeToday'))); + expect(escaped, 0); + }); + + testWidgets('a refused escape says so and does not publish', (tester) async { + native + ..state = nativeState() + ..escapeResult = false; + await pumpCard(tester); + await tester.tap(find.text('That was a bug')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.enterText(find.byType(TextField), 'y' * escapeReasonMinChars); + await tester.pump(); + await tester.tap(find.text('Excuse this morning')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.textContaining('refused'), findsOneWidget); + expect(await EscapeLog.load(), isEmpty); + }); + + testWidgets('the home body carries the card', (tester) async { + native.state = nativeState(); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: HomeBody( + time: '07:00', + baseline: '07:00', + isOverridden: false, + busy: false, + status: '', + configured: true, + onPickEarlier: () {}, + ), + ), + ), + ); + await tester.pump(); + expect(find.byType(StakesCard), findsOneWidget); + }); +} diff --git a/scripts/pc/ring_alsa.sh b/scripts/pc/ring_alsa.sh new file mode 100755 index 0000000..4bd1c6a --- /dev/null +++ b/scripts/pc/ring_alsa.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# ============================================================================ +# Ring the PC speakers straight through ALSA, as root. +# +# ExecStart of wake-alarm-ring-alsa.service (installed to +# /usr/local/lib/wake-alarm/). Used by the PC fallback alarm when no +# graphical session owns the sound (night lockdown: nobody logged in, and +# the user is not in `audio`). Unmutes and maxes every playback control the +# speakers' card has, plays the rising WAV once, then loops the full-volume +# one until systemd stops the unit (RuntimeMaxSec is the backstop). +# +# Usage: ring_alsa.sh +# ============================================================================ + +set -euo pipefail + +# The speakers: ALC897 Analog on the motherboard codec (`aplay -l`). +# Overridable only so the vmbox gate can aim it at snd-dummy. +readonly CARD="${WAKE_ALARM_CARD:-Generic_1}" +readonly DEVICE="plughw:CARD=${CARD},DEV=0" +# Playback controls that may gate the speakers; only those present are set. +# Front is the rear line-out jack on this board. +readonly CONTROLS=(Master PCM Speaker Headphone Front) +# A failed aplay (device briefly held elsewhere) must not spin the CPU. +readonly RETRY_SECONDS=1 + +usage() { + echo "Usage: $(basename "$0") " >&2 + exit 2 +} + +unmute_all() { + local control + for control in "${CONTROLS[@]}"; do + if amixer -c "$CARD" sget "$control" >/dev/null 2>&1; then + # Volume and switch separately: a control may have only one. + amixer -q -c "$CARD" sset "$control" 100% >/dev/null 2>&1 || true + amixer -q -c "$CARD" sset "$control" unmute >/dev/null 2>&1 || true + echo "set $control to 100%, unmuted" + fi + done +} + +play() { + aplay -q -D "$DEVICE" "$1" || sleep "$RETRY_SECONDS" +} + +main() { + [[ $# -eq 2 ]] || usage + local rise="$1" loop="$2" + [[ -r "$rise" && -r "$loop" ]] || { + echo "Error: cannot read $rise / $loop" >&2 + exit 1 + } + unmute_all + play "$rise" + while true; do + play "$loop" + done +} + +main "$@" diff --git a/scripts/pc/wake-alarm-powerkey.conf b/scripts/pc/wake-alarm-powerkey.conf new file mode 100644 index 0000000..04754b8 --- /dev/null +++ b/scripts/pc/wake-alarm-powerkey.conf @@ -0,0 +1,6 @@ +# Installed by wake-alarm's install.sh in pc_wake.json "hibernate" mode only. +# The power key hibernates, so the RTC alarm armed by wake-alarm-rtc.timer +# resumes the PC before a stick-day alarm (verified: S4 wake works on this +# host). A typed `shutdown` stays a real poweroff. +[Login] +HandlePowerKey=hibernate diff --git a/scripts/pc/wake-alarm-ring-alsa.service b/scripts/pc/wake-alarm-ring-alsa.service new file mode 100644 index 0000000..d44e9ef --- /dev/null +++ b/scripts/pc/wake-alarm-ring-alsa.service @@ -0,0 +1,14 @@ +[Unit] +Description=Wake Alarm fallback ring straight to the speakers (ALSA, root) +Documentation=https://github.com/kuhyx/wake-alarm + +[Service] +# Started and stopped only by the user's wake-alarm-fallback.service through +# `sudo -n systemctl start|stop` (see /etc/sudoers.d/wake-alarm), when no +# graphical session owns the sound: the night lockdown masks lightdm, and +# the user is not in `audio`, so only root can reach the card. +Type=simple +ExecStart=/usr/local/lib/wake-alarm/ring_alsa.sh /usr/local/share/wake-alarm/ring.wav /usr/local/share/wake-alarm/ring_loop.wav +# Backstop for a fallback tick that died without stopping this unit: never +# ring longer than the 30-min cap plus a minute. +RuntimeMaxSec=31min diff --git a/scripts/vmbox/guest_cases.sh b/scripts/vmbox/guest_cases.sh index c925c48..3a1ac95 100755 --- a/scripts/vmbox/guest_cases.sh +++ b/scripts/vmbox/guest_cases.sh @@ -16,6 +16,8 @@ set -uo pipefail readonly PY=/usr/bin/python3 FAILURES=0 +# shellcheck source=guest_cases_v2.sh source-path=SCRIPTDIR +source "$(dirname "$(readlink -f "$0")")/guest_cases_v2.sh" pass() { echo "PASS $1" @@ -86,6 +88,7 @@ main() { case_units case_trigger_dry_run case_session_refresh + run_v2_cases ;; *) echo "usage: $0 [all]" >&2 diff --git a/scripts/vmbox/guest_cases_v2.sh b/scripts/vmbox/guest_cases_v2.sh new file mode 100644 index 0000000..c418bf5 --- /dev/null +++ b/scripts/vmbox/guest_cases_v2.sh @@ -0,0 +1,130 @@ +#!/bin/bash + +# ============================================================================ +# wake_alarm vmbox harness — guest cases for wake enforcement v2 +# (DOCS-wake-enforcement.md): the fallback/rtc/audit timers, the root ring +# unit, the sudoers merge, the logind power-key drop-in, a real RTC arm, and +# uninstall undoing all of it. Sourced by guest_cases.sh, which owns +# pass/fail/FAILURES and the sentinel. +# ============================================================================ + +readonly RING_UNIT=/etc/systemd/system/wake-alarm-ring-alsa.service +readonly RING_SCRIPT=/usr/local/lib/wake-alarm/ring_alsa.sh +readonly RING_WAV_DIR=/usr/local/share/wake-alarm +readonly SUDOERS=/etc/sudoers.d/wake-alarm +readonly POWERKEY_DROPIN=/etc/systemd/logind.conf.d/wake-alarm-powerkey.conf +readonly WAKEALARM=/sys/class/rtc/rtc0/wakealarm + +# A timer present but disabled fails silently every morning. +case_v2_timers() { + local timer state + for timer in wake-alarm-fallback.timer wake-alarm-rtc.timer wake-alarm-audit.timer; do + state="$(systemctl --user is-enabled "$timer" 2>&1)" + if [[ "$state" == "enabled" ]]; then + pass "${timer%.timer}-enabled" + else + fail "${timer%.timer}-enabled" "expected enabled, got '$state'" + fi + done +} + +case_ring_unit_installed() { + local name="ring-alsa-installed" + if [[ -f "$RING_UNIT" && -x "$RING_SCRIPT" && -s "$RING_WAV_DIR/ring.wav" && + -s "$RING_WAV_DIR/ring_loop.wav" ]]; then + pass "$name" + else + fail "$name" "unit, script or WAVs missing" + fi +} + +# The root ring is what plays during the night lockdown; prove the script +# drives a real ALSA card (snd-dummy here) as root and keeps looping. +case_ring_script_plays() { + local name="ring-alsa-plays" rc=0 + sudo modprobe snd-dummy >/dev/null 2>&1 || true + if ! sudo aplay -l 2>/dev/null | grep -q Dummy; then + fail "$name" "snd-dummy did not load" + return + fi + sudo WAKE_ALARM_CARD=Dummy timeout 4 "$RING_SCRIPT" \ + "$RING_WAV_DIR/ring.wav" "$RING_WAV_DIR/ring_loop.wav" >/dev/null 2>&1 || rc=$? + # 124 = still playing when timeout killed it: the loop is alive. + if [[ $rc -eq 124 ]]; then + pass "$name" + else + fail "$name" "expected timeout (124) while looping, got $rc" + fi +} + +case_sudoers() { + local name="sudoers-merged" + if ! sudo visudo -cf "$SUDOERS" >/dev/null 2>&1; then + fail "$name" "visudo rejects $SUDOERS" + return + fi + if sudo grep -q "systemctl start wake-alarm-ring-alsa.service" "$SUDOERS" && + sudo grep -q "systemctl stop wake-alarm-ring-alsa.service" "$SUDOERS" && + sudo grep -q "/usr/sbin/rtcwake" "$SUDOERS"; then + pass "$name" + else + fail "$name" "start/stop/rtcwake lines missing" + fi +} + +case_powerkey_dropin() { + local name="powerkey-dropin-hibernate" + if grep -qx "HandlePowerKey=hibernate" "$POWERKEY_DROPIN" 2>/dev/null; then + pass "$name" + else + fail "$name" "drop-in missing in default (hibernate) mode" + fi +} + +# A real arm through the production sudo path; the guest has no Firebase, +# so the alarm falls back to 08:00 and the next Tue/Wed/Thu is the target. +case_rtc_arm() { + local name="rtc-arm-real" armed + # A Monday evening, so the next morning is a stick day (Tuesday). The + # guest is disposable; its clock is fair game. + sudo timedatectl set-ntp false >/dev/null 2>&1 || true + sudo date -s "next monday 21:00" >/dev/null + # Move the RTC too: rtcwake arms relative to it, and sysfs reports it. + sudo hwclock --systohc --utc >/dev/null 2>&1 + "$PY" -m wake_alarm._rtc --arm 2>&1 | sed 's/^/ rtc: /' || true + armed="$(cat "$WAKEALARM" 2>/dev/null)" + if [[ -n "$armed" ]] && "$PY" -m wake_alarm._rtc --show 2>/dev/null | grep -q "$armed"; then + pass "$name" + else + fail "$name" "wakealarm='${armed}' not reported by --show" + fi +} + +# Last: uninstall must remove everything except the sudoers file. +case_uninstall() { + local name="uninstall-cleans" leftovers=() + (cd ~/wake-alarm && bash uninstall.sh >/dev/null 2>&1) || true + [[ -e "$RING_UNIT" ]] && leftovers+=("$RING_UNIT") + [[ -e "$RING_SCRIPT" ]] && leftovers+=("$RING_SCRIPT") + [[ -e "$POWERKEY_DROPIN" ]] && leftovers+=("$POWERKEY_DROPIN") + [[ -n "$(cat "$WAKEALARM" 2>/dev/null)" ]] && leftovers+=("rtc alarm") + systemctl --user is-enabled wake-alarm-fallback.timer >/dev/null 2>&1 && + leftovers+=("fallback timer") + # /etc/sudoers.d is root-only (0750): stat it as root. + sudo test -e "$SUDOERS" || leftovers+=("sudoers REMOVED (must stay)") + if [[ ${#leftovers[@]} -eq 0 ]]; then + pass "$name" + else + fail "$name" "${leftovers[*]}" + fi +} + +run_v2_cases() { + case_v2_timers + case_ring_unit_installed + case_ring_script_plays + case_sudoers + case_powerkey_dropin + case_rtc_arm + case_uninstall +} diff --git a/scripts/vmbox/harness.sh b/scripts/vmbox/harness.sh index 8434ca6..eb1602a 100755 --- a/scripts/vmbox/harness.sh +++ b/scripts/vmbox/harness.sh @@ -34,7 +34,7 @@ readonly SCRIPT_NAME # dies with "libtk8.6.so: cannot open shared object file" without it. Measured # in the guest, not guessed -- dropping this package is what broke the harness # when the alarm window was deleted. -readonly GUEST_PACKAGES="tk" +readonly GUEST_PACKAGES="tk alsa-utils" # Printed by guest_cases.sh only when every case passed. See run_cases(). readonly GUEST_OK_SENTINEL="ALL-CASES-PASSED" diff --git a/uninstall.sh b/uninstall.sh index c635a53..183bc6c 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -21,6 +21,16 @@ set -euo pipefail readonly SLEEP_HOOK_DST="/usr/lib/systemd/system-sleep/wake-alarm.sh" readonly SHUTDOWN_WRAPPER_DST="/usr/local/bin/shutdown" readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user" +# Enforcement v2 (DOCS-wake-enforcement.md): user units, then root pieces. +readonly V2_UNITS=(wake-alarm-fallback wake-alarm-rtc wake-alarm-audit) +readonly ALSA_UNIT="wake-alarm-ring-alsa.service" +readonly ROOT_FILES=( + "/etc/systemd/system/$ALSA_UNIT" + "/usr/local/lib/wake-alarm/ring_alsa.sh" + "/usr/local/share/wake-alarm/ring.wav" + "/usr/local/share/wake-alarm/ring_loop.wav" +) +readonly POWERKEY_DST="/etc/systemd/logind.conf.d/wake-alarm-powerkey.conf" RETIRED_ONLY=0 @@ -82,12 +92,40 @@ remove_trigger() { "$SYSTEMD_USER_DIR/wake-alarm-session.timer" } +remove_enforcement() { + local unit path + for unit in "${V2_UNITS[@]}"; do + systemctl --user disable --now "$unit.timer" >/dev/null 2>&1 || true + systemctl --user stop "$unit.service" >/dev/null 2>&1 || true + rm -f "$SYSTEMD_USER_DIR/$unit.service" "$SYSTEMD_USER_DIR/$unit.timer" + done + echo " Fallback, RTC and audit timers removed" + + sudo systemctl stop "$ALSA_UNIT" >/dev/null 2>&1 || true + for path in "${ROOT_FILES[@]}"; do + sudo rm -f "$path" + done + sudo rmdir /usr/local/lib/wake-alarm /usr/local/share/wake-alarm 2>/dev/null || true + sudo systemctl daemon-reload + echo " $ALSA_UNIT, its script and the ring WAVs removed" + + if sudo test -f "$POWERKEY_DST"; then + sudo rm -f "$POWERKEY_DST" + # Reload, NEVER restart: restarting logind kills the graphical session. + sudo systemctl kill -s HUP systemd-logind + echo " Power key back to the logind default" + fi + # Disarm a pending stick-day wake, so the PC does not start by itself. + sudo -n /usr/sbin/rtcwake -m disable >/dev/null 2>&1 || true +} + main() { echo "=== Removing the PC's waking machinery ===" remove_retired if [[ $RETIRED_ONLY -eq 0 ]]; then remove_trigger + remove_enforcement echo " Note: the workout lock no longer starts at the phone's alarm time." fi @@ -95,7 +133,8 @@ main() { echo echo "Left in place on purpose:" - echo " /etc/sudoers.d/wake-alarm — screen-locker's own rtcwake path needs it." + echo " /etc/sudoers.d/wake-alarm — never deleted by an uninstall (AGENTS.md);" + echo " its start/stop lines are inert once $ALSA_UNIT is gone." echo echo "Verify: command -v shutdown # expect /usr/bin/shutdown" } diff --git a/wake-alarm-audit.service b/wake-alarm-audit.service new file mode 100644 index 0000000..55e38ac --- /dev/null +++ b/wake-alarm-audit.service @@ -0,0 +1,9 @@ +[Unit] +Description=Wake Alarm daily audit (incident report for the morning) +Documentation=https://github.com/kuhyx/wake-alarm + +[Service] +Type=oneshot +# Writes ~/data/wake-alarm/incidents/.md: merged phone + PC timeline, +# expected-vs-observed checks, escapes and mismatches first. +ExecStart=/usr/bin/python -m wake_alarm._audit diff --git a/wake-alarm-audit.timer b/wake-alarm-audit.timer new file mode 100644 index 0000000..8954b28 --- /dev/null +++ b/wake-alarm-audit.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Wake Alarm daily audit at 11:05 +Documentation=https://github.com/kuhyx/wake-alarm + +[Timer] +# After the carrot's 11:00 boundary, so the morning is over and settled. +OnCalendar=*-*-* 11:05:00 +# A PC that was off at 11:05 audits as soon as it is back. +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/wake-alarm-fallback.service b/wake-alarm-fallback.service new file mode 100644 index 0000000..e036041 --- /dev/null +++ b/wake-alarm-fallback.service @@ -0,0 +1,16 @@ +[Unit] +Description=Wake Alarm PC fallback alarm tick (rings when the phone stays silent) +Documentation=https://github.com/kuhyx/wake-alarm + +[Service] +Type=oneshot +# One tick of DOCS-wake-enforcement.md's table. When it decides to ring it +# rings in THIS process, polling the phone every 20 s, until a session +# starts, an escape arrives or the 30-min cap; while it runs, the timer's +# next minute is a no-op (the unit is still active), so no PID file. +ExecStart=/usr/bin/python -m wake_alarm._fallback --tick +# A ring lasts at most FALLBACK_RING_CAP (30 min); 40 leaves room for slow +# Firebase polls without ever cutting a ring short. SIGTERM becomes +# SystemExit, and the backend (pw-play, or the root ALSA unit) is stopped +# in a finally block on the way out. +TimeoutStartSec=40min diff --git a/wake-alarm-fallback.timer b/wake-alarm-fallback.timer new file mode 100644 index 0000000..e334066 --- /dev/null +++ b/wake-alarm-fallback.timer @@ -0,0 +1,14 @@ +[Unit] +Description=Wake Alarm PC fallback alarm, once a minute through the morning +Documentation=https://github.com/kuhyx/wake-alarm + +[Timer] +# 04:00-09:59 covers A-5min..A+45min for any alarm the phone can set (A is +# clamped to <= 08:00). Outside that window a tick reads only local files. +OnCalendar=*-*-* 04..09:*:00 +AccuracySec=1s +# Catch up at once after the RTC resume or a boot. +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/wake-alarm-rtc.service b/wake-alarm-rtc.service new file mode 100644 index 0000000..8b86cf5 --- /dev/null +++ b/wake-alarm-rtc.service @@ -0,0 +1,11 @@ +[Unit] +Description=Wake Alarm RTC arming (wake the PC before a stick-day alarm) +Documentation=https://github.com/kuhyx/wake-alarm + +[Service] +Type=oneshot +# Arms the CMOS alarm for A - 10 min of the next Tue/Wed/Thu morning through +# the existing NOPASSWD `sudo -n /usr/sbin/rtcwake` entry; when the next +# morning is not one to wake for, clears an alarm it armed itself (and only +# that one). rtc_armed / rtc_skip / rtc_disarmed land in events.jsonl. +ExecStart=/usr/bin/python -m wake_alarm._rtc --arm diff --git a/wake-alarm-rtc.timer b/wake-alarm-rtc.timer new file mode 100644 index 0000000..3671acf --- /dev/null +++ b/wake-alarm-rtc.timer @@ -0,0 +1,18 @@ +[Unit] +Description=Wake Alarm RTC arming, every 15 min all day and on boot +Documentation=https://github.com/kuhyx/wake-alarm + +[Timer] +# Every 15 min, all day: a PC hibernated at 16:00 the day before a stick day +# must still have been armed. There is no sleep.target in the *user* +# manager, so resume is covered by the calendar itself: a calendar timer +# that elapsed while the system slept "will catch up" once it is resumed +# (systemd.timer(5)), and with a 15-min cadence every sleep longer than a +# quarter hour re-arms (or disarms) right after resume. +OnCalendar=*-*-* *:00/15:00 +# Fresh boot (a CMOS alarm survives a poweroff, but re-check it anyway). +OnBootSec=2min +AccuracySec=1min + +[Install] +WantedBy=timers.target diff --git a/wake_alarm/_audit.py b/wake_alarm/_audit.py new file mode 100644 index 0000000..5c0c206 --- /dev/null +++ b/wake_alarm/_audit.py @@ -0,0 +1,183 @@ +"""The daily wake audit: one Markdown report per morning, for Claude to read. + +``wake-alarm-audit.timer`` runs it at 11:05; by hand:: + + python -m wake_alarm._audit [YYYY-MM-DD] [--dry-run] + +It merges the phone's ``phone_events`` with the PC's named ``events.jsonl`` +lines, boots and resumes into one timeline, runs the expected-vs-observed +checks (``_audit_checks``) and writes ``INCIDENTS_DIR/.md`` with every +escape and mismatch first, under ``## Flags``. Read-only everywhere except +that one file (and ``audit_written``); ``--dry-run`` prints without writing. +Outside the repo on purpose: the report holds personal data. +""" + +from __future__ import annotations + +import argparse +from datetime import date, datetime +import sys +from typing import TYPE_CHECKING + +import freedays + +from wake_alarm._audit_checks import alarm_from_events, flags, run_checks +from wake_alarm._audit_evidence import Check, Evidence, PcSide, PhoneSide +from wake_alarm._audit_sources import ( + boots, + phone_window, + read_pc_log, + resumes, + timeline_window, +) +from wake_alarm._constants import INCIDENTS_DIR +from wake_alarm._enforcement_payload import EnforcementView, enforcement_view +from wake_alarm._enforcement_records import parse_instant +from wake_alarm._events import emit +from wake_alarm._fallback_policy import alarm_instant +from wake_alarm._logging_setup import configure_logging +from wake_alarm._sync import get_alarm +from wake_alarm._sync_transport import fetch_firebase_logs +from wake_alarm._workday_penalty import issued_date + +if TYPE_CHECKING: + from wake_alarm._enforcement_records import PhoneEvent + +_MARK = {True: "[x]", False: "[ ]", None: "[-]"} + + +def gather(day: date) -> tuple[Evidence, bool]: + """Collect the evidence for ``day``; second item is Firebase reachability.""" + published = fetch_firebase_logs() + view = enforcement_view(published) if published is not None else EnforcementView() + start, end = phone_window(day) + pc, pc_seen = read_pc_log(day) + alarm_at, source = alarm_from_events(pc, day), "PC fallback tick" + if alarm_at is None: + alarm_at, source = alarm_instant(get_alarm(), day), "current alarm, assumed" + phone = [ + e + for e in view.phone_events + if (at := parse_instant(e.at)) is not None and start <= at < end + ] + evidence = Evidence( + day=day, + stick_day=freedays.is_workday(day), + free_day=freedays.is_free_day(day), + alarm_at=alarm_at, + alarm_source=source, + pc_side=PcSide( + events=pc, + seen=pc_seen, + ups=sorted(boots(day) + resumes(day), key=lambda u: u.at), + penalty_issued=issued_date(), + ), + phone_side=PhoneSide( + events=sorted(phone, key=lambda e: parse_instant(e.at) or start), + escapes=[e for e in view.escapes if e.date == day.isoformat()], + ring_started=view.ring_started_on(day.isoformat()), + stick_state=view.stick_state, + ), + ) + return evidence, published is not None + + +def _shown(ev: Evidence, at: datetime, phone: PhoneEvent | None = None) -> bool: + """Inside the timeline window, or a late check that judged this day.""" + start, end = timeline_window(ev.day) + if start <= at < end: + return True + return phone is not None and phone.data.get("date") == ev.day.isoformat() + + +def _row(at: datetime, source: str, name: str, data: dict[str, object]) -> str: + """One ``| time | source | event | details |`` row.""" + detail = ", ".join(f"{k}={v}" for k, v in sorted(data.items())) + return f"| {at:%m-%d %H:%M:%S} | {source} | {name} | {detail} |" + + +def _timeline(ev: Evidence) -> list[str]: + """The merged rows, oldest first.""" + rows = [ + (pc.at, _row(pc.at, "PC", pc.event, pc.fields)) + for pc in ev.pc + if _shown(ev, pc.at) + ] + for phone in ev.phone: + at = parse_instant(phone.at) + if at is not None and _shown(ev, at, phone): + rows.append((at, _row(at, "phone", phone.kind, phone.data))) + rows.extend( + (up.at, _row(up.at, "kernel", up.kind, {})) + for up in ev.ups + if _shown(ev, up.at) + ) + return [row for _, row in sorted(rows, key=lambda r: r[0])] + + +def render(ev: Evidence, checks: list[Check], *, reachable: bool) -> str: + """The whole report.""" + kind = "free day" if ev.free_day else "stick day" if ev.stick_day else "carrot day" + lines = [f"# Wake audit {ev.day} ({ev.day:%a}, {kind})", "", "## Flags", ""] + flagged = flags(ev, checks) + if not reachable: + flagged.append("MISMATCH firebase: unreachable -- phone side not audited") + lines.extend(f"- {line}" for line in flagged or ["none"]) + lines += ["", "## Checks", ""] + lines.extend(f"- {_MARK[c.ok]} {c.name}: {c.detail}" for c in checks) + state = ev.stick_state + lines += [ + "", + "## Phone state now", + "", + f"- stick_state: {state if state is not None else 'not published'}", + f"- workday_penalty.json issued: {ev.penalty_issued or 'none'}", + "", + "## Timeline", + "", + "| time | source | event | details |", + "|---|---|---|---|", + ] + lines.extend(_timeline(ev) or ["| | | (nothing recorded) | |"]) + return "\n".join(lines) + "\n" + + +def write_report(day: date, text: str) -> str: + """Write the report; returns its path.""" + path = INCIDENTS_DIR / f"{day.isoformat()}.md" + INCIDENTS_DIR.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return str(path) + + +def main(argv: list[str] | None = None) -> int: + """Entry point; exit 1 when the report could not be written.""" + parser = argparse.ArgumentParser(description="Audit one wake-up") + parser.add_argument("day", nargs="?", type=date.fromisoformat, default=None) + parser.add_argument("--dry-run", action="store_true", help="print, write nothing") + args = parser.parse_args(sys.argv[1:] if argv is None else argv) + day = args.day or datetime.now().astimezone().date() + evidence, reachable = gather(day) + checks = run_checks(evidence) + text = render(evidence, checks, reachable=reachable) + sys.stdout.write(text) + if args.dry_run: + return 0 + configure_logging(component="audit") + try: + path = write_report(day, text) + except OSError as exc: + sys.stderr.write(f"could not write the report: {exc}\n") + return 1 + emit( + "audit_written", + path, + day=day.isoformat(), + path=path, + flags=len(flags(evidence, checks)), + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/wake_alarm/_audit_checks.py b/wake_alarm/_audit_checks.py new file mode 100644 index 0000000..f869290 --- /dev/null +++ b/wake_alarm/_audit_checks.py @@ -0,0 +1,148 @@ +"""Expected-vs-observed for one morning: pure, evidence in, checks out. + +Each check is ``ok`` (True), a mismatch (False) or not applicable (None). +Escapes and mismatches become the report's ``## Flags`` -- escapes first, +because every use of the hatch must be seen. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +from wake_alarm._audit_evidence import ( + Check, + Evidence, + bad, + na, + ok, + pc_on_day, + phone_on_day, +) +from wake_alarm._audit_stick import check_lockdown, check_penalty +from wake_alarm._constants import RTC_WAKE_LEAD +from wake_alarm._enforcement_payload import is_honoured +from wake_alarm._enforcement_records import parse_instant + +if TYPE_CHECKING: + from datetime import date, datetime + + from wake_alarm._audit_sources import PcEvent + +_FINAL_STOPS = frozenset({"escape", "session_started", "cap", "free_day"}) +_RING_KINDS = frozenset({"ring_screen_opened", "ring_started_published"}) +# alarm_fired.data.reason: only the daily ring is the morning's ring -- a +# free_day firing rings nothing, and a rering presupposes an earlier ring. +_NOT_THE_MORNING_RING = frozenset({"free_day", "rering"}) + + +def alarm_from_events(pc: list[PcEvent], day: date) -> datetime | None: + """The ``A`` the fallback tick used on ``day``, if it ran.""" + for event in pc: + alarm = event.fields.get("alarm") + if event.event == "fallback_tick" and isinstance(alarm, str): + at = parse_instant(alarm) + if at is not None and at.date() == day: + return at + return None + + +def check_alarm(ev: Evidence) -> Check: + """The alarm for the day is known.""" + if ev.alarm_at is None: + return bad("alarm known", "no alarm time known for this day") + return ok("alarm known", f"{ev.alarm_at:%H:%M} ({ev.alarm_source})") + + +def check_rtc(ev: Evidence) -> Check: + """On a stick day the RTC was armed the night before.""" + if not ev.stick_day or ev.free_day: + return na("RTC armed the night before", "not a stick day") + armed = [ + e + for e in ev.pc + if e.event == "rtc_armed" and e.fields.get("target_date") == ev.day.isoformat() + ] + if not armed: + return bad("RTC armed the night before", "no rtc_armed for this day") + return ok( + "RTC armed the night before", + f"{armed[-1].fields.get('wake_at')} (logged {armed[-1].at:%H:%M})", + ) + + +def check_up_by_alarm(ev: Evidence) -> Check: + """On a stick day the PC was up by ``A``.""" + if not ev.stick_day or ev.free_day or ev.alarm_at is None: + return na("PC up by A", "not a stick day or no alarm") + alarm = ev.alarm_at + lead = alarm - RTC_WAKE_LEAD - timedelta(minutes=5) + ups = [u for u in ev.ups if u.at.date() == ev.day and u.at <= alarm] + if ups: + return ok("PC up by A", f"{ups[-1].kind} at {ups[-1].at:%H:%M:%S}") + alive = [at for at in ev.pc_seen if lead <= at <= alarm] + if alive: + return ok("PC up by A", f"already up (logged at {alive[0]:%H:%M})") + return bad("PC up by A", f"no boot, resume or PC event by {alarm:%H:%M}") + + +def check_ring(ev: Evidence) -> Check: + """Something rang: the phone's ring screen, or the PC fallback.""" + if ev.free_day: + return na("something rang", "free day") + fallback = pc_on_day(ev, "fallback_ring_start") + phone = ev.ring_started or any( + e.kind in _RING_KINDS + or ( + e.kind == "alarm_fired" + and e.data.get("reason") not in _NOT_THE_MORNING_RING + ) + for e in phone_on_day(ev) + ) + if not (phone or fallback): + return bad("something rang", "no ring_started and no fallback ring") + parts = (["phone"] if phone else []) + (["PC fallback"] if fallback else []) + return ok("something rang", " + ".join(parts)) + + +def check_fallback_stop(ev: Evidence) -> Check: + """Every fallback ring stopped, and for a contract reason.""" + starts = pc_on_day(ev, "fallback_ring_start") + if not starts: + return na("fallback stopped correctly", "fallback did not ring") + stops = pc_on_day(ev, "fallback_ring_stop") + reasons = [str(e.fields.get("stop_reason")) for e in stops] + if len(stops) < len(starts) or (reasons and reasons[-1] not in _FINAL_STOPS): + return bad( + "fallback stopped correctly", + f"{len(starts)} start(s), stops: {', '.join(reasons) or 'none'}", + ) + return ok("fallback stopped correctly", ", ".join(reasons)) + + +CHECKS = ( + check_alarm, + check_rtc, + check_up_by_alarm, + check_ring, + check_fallback_stop, + check_penalty, + check_lockdown, +) + + +def run_checks(ev: Evidence) -> list[Check]: + """Every check, in report order.""" + return [check(ev) for check in CHECKS] + + +def flags(ev: Evidence, checks: list[Check]) -> list[str]: + """Escapes first, then every mismatch.""" + lines = [] + for escape in ev.escapes: + honoured = "" if is_honoured(escape) else " (IGNORED by the PC: " + if honoured: + honoured += "after the last day or reason too short)" + lines.append(f"ESCAPE {escape.at or escape.date}: {escape.reason!r}{honoured}") + lines.extend(f"MISMATCH {c.name}: {c.detail}" for c in checks if c.ok is False) + return lines diff --git a/wake_alarm/_audit_evidence.py b/wake_alarm/_audit_evidence.py new file mode 100644 index 0000000..5b2f024 --- /dev/null +++ b/wake_alarm/_audit_evidence.py @@ -0,0 +1,137 @@ +"""What the daily audit knows about one morning, as plain data. + +Split from ``_audit_checks`` (the rules) so the evidence shape can be read on +its own: the PC's side (named events, when anything was logged, boots and +resumes, the penalty file) and the phone's side (its event log, escapes, the +ring screen and the stick streak), plus the day and its alarm. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from wake_alarm._enforcement_records import parse_instant + +if TYPE_CHECKING: + from datetime import date, datetime + + from wake_alarm._audit_sources import PcEvent, Up + from wake_alarm._enforcement_records import Escape, PhoneEvent, StickState + + +@dataclass(frozen=True) +class PcSide: + """The PC's own record of the morning.""" + + events: list[PcEvent] = field(default_factory=list) + seen: list[datetime] = field(default_factory=list) + ups: list[Up] = field(default_factory=list) + penalty_issued: str | None = None + + +@dataclass(frozen=True) +class PhoneSide: + """What the phone published about the morning.""" + + events: list[PhoneEvent] = field(default_factory=list) + escapes: list[Escape] = field(default_factory=list) + ring_started: bool = False + stick_state: StickState | None = None + + +@dataclass(frozen=True) +class Evidence: + """Everything the audit gathered about ``day``.""" + + day: date + stick_day: bool + free_day: bool + alarm_at: datetime | None + alarm_source: str + pc_side: PcSide = field(default_factory=PcSide) + phone_side: PhoneSide = field(default_factory=PhoneSide) + + @property + def pc(self) -> list[PcEvent]: + """Named PC events.""" + return self.pc_side.events + + @property + def pc_seen(self) -> list[datetime]: + """When the PC logged anything at all.""" + return self.pc_side.seen + + @property + def ups(self) -> list[Up]: + """Boots and resumes.""" + return self.pc_side.ups + + @property + def penalty_issued(self) -> str | None: + """``issued_date`` of the penalty file, if one is on disk.""" + return self.pc_side.penalty_issued + + @property + def phone(self) -> list[PhoneEvent]: + """The phone's own events.""" + return self.phone_side.events + + @property + def escapes(self) -> list[Escape]: + """Escapes excusing this day (honoured or not).""" + return self.phone_side.escapes + + @property + def ring_started(self) -> bool: + """Whether the phone's ring screen opened this day.""" + return self.phone_side.ring_started + + @property + def stick_state(self) -> StickState | None: + """The phone's streak, as published now.""" + return self.phone_side.stick_state + + +@dataclass(frozen=True) +class Check: + """One expectation and what was observed.""" + + name: str + ok: bool | None + detail: str + + +def ok(name: str, detail: str) -> Check: + """An expectation that held.""" + return Check(name, ok=True, detail=detail) + + +def bad(name: str, detail: str) -> Check: + """A mismatch: lands under ``## Flags``.""" + return Check(name, ok=False, detail=detail) + + +def na(name: str, detail: str) -> Check: + """Not applicable to this morning.""" + return Check(name, ok=None, detail=detail) + + +def pc_on_day(ev: Evidence, name: str) -> list[PcEvent]: + """PC events called ``name`` on the audited day itself.""" + return [e for e in ev.pc if e.event == name and e.at.date() == ev.day] + + +def phone_on_day(ev: Evidence) -> list[PhoneEvent]: + """Phone events stamped on the audited day itself.""" + on_day: list[PhoneEvent] = [] + for event in ev.phone: + at = parse_instant(event.at) + if at is not None and at.date() == ev.day: + on_day.append(event) + return on_day + + +def phone_kinds(ev: Evidence) -> set[str]: + """Phone event kinds recorded on the audited day.""" + return {event.kind for event in phone_on_day(ev)} diff --git a/wake_alarm/_audit_sources.py b/wake_alarm/_audit_sources.py new file mode 100644 index 0000000..87eb2ce --- /dev/null +++ b/wake_alarm/_audit_sources.py @@ -0,0 +1,184 @@ +"""Where the daily audit's evidence comes from -- read-only, all of it. + +* PC events: every ``events.jsonl*`` line (the live file rotates at 5 MB) + that carries a top-level ``event`` key -- the named events only, or the + timeline would be hundreds of lines of routine sync chatter. +* Boots: ``journalctl --list-boots -o json``. +* Resumes: kernel ``Waking up from system sleep state`` lines, read with + ``_TRANSPORT=kernel`` and a time range -- ``-k`` would cover only the + current boot. + +Every ``journalctl`` call goes through ``_run``, the seam the suite replaces. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +import json +import subprocess +from typing import TYPE_CHECKING + +from wake_alarm._constants import LOG_JSONL_FILE + +if TYPE_CHECKING: + from datetime import date + +_RESUME_PATTERN = "Waking up from system sleep state" +_USEC = 1_000_000 + + +@dataclass(frozen=True) +class PcEvent: + """One named line of ``events.jsonl``.""" + + at: datetime + event: str + message: str + fields: dict[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True) +class Up: + """The PC became usable: a boot, or a resume from sleep.""" + + at: datetime + kind: str + + +# The phone judges up to this many past mornings on a boot/watchdog check. +STICK_CATCH_UP_DAYS = 5 + + +def _midnight(day: date) -> datetime: + """Local midnight starting ``day``.""" + return datetime.combine(day, datetime.min.time()).astimezone() + + +def audit_window(day: date) -> tuple[datetime, datetime]: + """PC evidence: from the start of the day before to the end of ``day``. + + The RTC is re-armed every 15 min, so the first ``rtc_armed`` for ``day`` + is logged any time after the previous morning's wake instant. + """ + start = _midnight(day) + return start - timedelta(days=1), start + timedelta(days=1) + + +def phone_window(day: date) -> tuple[datetime, datetime]: + """Phone evidence: also the days after, when a late check judged ``day``.""" + start, end = audit_window(day) + return start, end + timedelta(days=STICK_CATCH_UP_DAYS) + + +def timeline_window(day: date) -> tuple[datetime, datetime]: + """What the timeline shows: ``day`` and the evening before.""" + start = _midnight(day) + return start - timedelta(hours=6), start + timedelta(days=1) + + +def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]: + """Run ``cmd``; the seam tests replace.""" + return subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60) + + +def _journal(cmd: list[str]) -> str: + """``cmd``'s stdout, or ``""`` when journalctl is missing or failed.""" + try: + result = _run(cmd) + except OSError, subprocess.SubprocessError: + return "" + return result.stdout if result.returncode == 0 else "" + + +def _pc_line(line: str) -> tuple[datetime, PcEvent | None] | None: + """One JSONL line: its instant, and the named event it carries (if any).""" + try: + raw = json.loads(line) + at = datetime.fromisoformat(str(raw.get("ts") or "")).astimezone() + except ValueError, AttributeError: + return None + if not isinstance(raw.get("event"), str): + return at, None + fields = raw.get("fields") + return at, PcEvent( + at=at, + event=raw["event"], + message=str(raw.get("msg") or ""), + fields=dict(fields) if isinstance(fields, dict) else {}, + ) + + +def read_pc_log(day: date) -> tuple[list[PcEvent], list[datetime]]: + """Inside :func:`audit_window`: named events, and when anything was logged. + + The second list proves the PC was running even when nothing named + happened (the trigger and the sync reader log routinely). + """ + start, end = audit_window(day) + named: list[PcEvent] = [] + seen: list[datetime] = [] + for path in sorted(LOG_JSONL_FILE.parent.glob(LOG_JSONL_FILE.name + "*")): + try: + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + for line in lines: + parsed = _pc_line(line) + if parsed is None or not start <= parsed[0] < end: + continue + seen.append(parsed[0]) + if parsed[1] is not None: + named.append(parsed[1]) + return sorted(named, key=lambda e: e.at), sorted(seen) + + +def boots(day: date) -> list[Up]: + """Boots that started inside :func:`audit_window`.""" + start, end = audit_window(day) + try: + listed = json.loads( + _journal(["journalctl", "--list-boots", "-o", "json", "--no-pager"]) or "[]" + ) + except ValueError: + return [] + ups: list[Up] = [] + for boot in listed if isinstance(listed, list) else []: + first = boot.get("first_entry") if isinstance(boot, dict) else None + if isinstance(first, int): + at = datetime.fromtimestamp(first / _USEC, tz=UTC).astimezone() + if start <= at < end: + ups.append(Up(at=at, kind="boot")) + return ups + + +def resumes(day: date) -> list[Up]: + """Kernel wake-from-sleep lines inside :func:`audit_window`.""" + start, end = audit_window(day) + out = _journal( + [ + "journalctl", + "_TRANSPORT=kernel", + "--since", + start.strftime("%Y-%m-%d %H:%M:%S"), + "--until", + end.strftime("%Y-%m-%d %H:%M:%S"), + "-g", + _RESUME_PATTERN, + "-o", + "json", + "--no-pager", + ] + ) + ups: list[Up] = [] + for line in out.splitlines(): + try: + entry = json.loads(line) + stamp = int(entry["__REALTIME_TIMESTAMP"]) + except ValueError, KeyError, TypeError: + continue + message = str(entry.get("MESSAGE") or "") + state = message.rsplit(" ", 1)[-1] if message else "?" + at = datetime.fromtimestamp(stamp / _USEC, tz=UTC).astimezone() + ups.append(Up(at=at, kind=f"resume {state}")) + return ups diff --git a/wake_alarm/_audit_stick.py b/wake_alarm/_audit_stick.py new file mode 100644 index 0000000..dae3ca2 --- /dev/null +++ b/wake_alarm/_audit_stick.py @@ -0,0 +1,135 @@ +"""The stick-day checks: penalty and lockdown, keyed by the day *judged*. + +The phone's ``stick_check`` carries ``data.date`` -- the morning it judged. +A boot or watchdog check catches up to five past days, so a Tuesday can be +judged on Thursday; keying by the event's ``at`` would pin that verdict on +the wrong morning. Verdict ``miss`` is the phone's miss; with no +``stick_check`` for the day, ``stick_state.last_miss`` stands in. + +A lockdown follows the check that found the miss, so it is expected on the +*judging* day. A miss caught up after its lockdown would already have ended +sends nothing (the phone's own rule), so a late judgement without a +``lockdown_sent`` is noted, not flagged. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from wake_alarm._audit_evidence import ( + bad, + na, + ok, + pc_on_day, + phone_kinds, + phone_on_day, +) +from wake_alarm._enforcement_payload import is_honoured +from wake_alarm._enforcement_records import parse_instant + +if TYPE_CHECKING: + from datetime import date + + from wake_alarm._audit_evidence import Check, Evidence + from wake_alarm._enforcement_records import PhoneEvent + +MISS = "miss" +_PROVISIONAL = "too_early" + + +def verdicts(ev: Evidence) -> list[PhoneEvent]: + """``stick_check`` events that judged the audited day, final ones only.""" + day = ev.day.isoformat() + return [ + e + for e in ev.phone + if e.kind == "stick_check" + and e.data.get("date") == day + and e.data.get("verdict") != _PROVISIONAL + ] + + +def miss_judged_on(ev: Evidence) -> date | None: + """The day the phone judged this morning a miss, if it did. + + Falls back to ``stick_state.last_miss`` (judged that same day, as far as + can be told) when no ``stick_check`` for the day was published. + """ + for event in verdicts(ev): + if event.data.get("verdict") == MISS: + at = parse_instant(event.at) + return at.date() if at is not None else ev.day + state = ev.stick_state + if state is not None and state.last_miss == ev.day.isoformat(): + return ev.day + return None + + +def _escaped(ev: Evidence) -> bool: + """Whether an honoured escape excuses the audited day.""" + return any(is_honoured(e) for e in ev.escapes) + + +def check_penalty(ev: Evidence) -> Check: + """The PC penalty agrees with the outcome and any escape.""" + name = "penalty consistent" + escaped = _escaped(ev) + if escaped and ev.penalty_issued == ev.day.isoformat(): + return bad(name, "escaped, but the penalty is on disk") + if escaped: + return ok(name, "escaped: no penalty") + if not ev.stick_day or ev.free_day: + return na(name, "not a stick day") + written = pc_on_day(ev, "penalty_written") + if miss_judged_on(ev) is not None and not written: + return bad(name, "phone counted a miss, PC wrote none") + return ok(name, "penalty written" if written else "no miss, no penalty") + + +def _sent_on(ev: Evidence, judged: date) -> list[PhoneEvent]: + """``lockdown_sent`` events stamped on the judging day.""" + sent: list[PhoneEvent] = [] + for event in ev.phone: + at = parse_instant(event.at) + if event.kind == "lockdown_sent" and at is not None and at.date() == judged: + sent.append(event) + return sent + + +def _lockdown_for_miss(ev: Evidence, judged: date) -> Check: + """The lockdown the miss judged on ``judged`` should have sent.""" + name = "lockdown consistent" + sent = _sent_on(ev, judged) + if not sent: + if judged > ev.day: + return ok(name, f"miss caught up on {judged}; lockdown may have expired") + return bad(name, "phone judged a miss, no lockdown_sent") + state = ev.stick_state + until = sent[-1].data.get("until") + if ( + state is not None + and state.last_miss == ev.day.isoformat() + and until != state.locked_through + ): + return bad( + name, + f"lockdown_sent until {until}, " + f"stick_state locked_through {state.locked_through}", + ) + return ok(name, f"until {until}") + + +def check_lockdown(ev: Evidence) -> Check: + """``lockdown_sent`` agrees with the verdict, and escapes lift it.""" + name = "lockdown consistent" + judged = miss_judged_on(ev) + sent_today = [e for e in phone_on_day(ev) if e.kind == "lockdown_sent"] + if _escaped(ev) and sent_today: + if "lift_sent" not in phone_kinds(ev): + return bad(name, "escaped, but no lift_sent") + return ok(name, "lifted after the escape") + if judged is not None: + return _lockdown_for_miss(ev, judged) + if sent_today and not _escaped(ev): + return bad(name, "lockdown_sent without a recorded miss") + return na(name, "no miss") diff --git a/wake_alarm/_cli_modes.py b/wake_alarm/_cli_modes.py new file mode 100644 index 0000000..bb87925 --- /dev/null +++ b/wake_alarm/_cli_modes.py @@ -0,0 +1,44 @@ +"""The ``-- | --dry-run | --show`` command line the v2 jobs share. + +``_fallback`` (``--tick``) and ``_rtc`` (``--arm``) take the same three +modes, plus ``--at`` so a dry run can be asked "what would happen at 07:05 +on Tuesday" without waiting for it -- the only way to check a morning-only +decision on the host in the afternoon. +""" + +from __future__ import annotations + +import argparse +from datetime import datetime +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + +def mode_parser(description: str, act: str, act_help: str) -> argparse.ArgumentParser: + """A parser with exactly one of ``--``, ``--dry-run``, ``--show``.""" + parser = argparse.ArgumentParser(description=description) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument(f"--{act}", action="store_true", help=act_help) + mode.add_argument("--dry-run", action="store_true", help="decide, change nothing") + mode.add_argument("--show", action="store_true", help="current state") + parser.add_argument( + "--at", + type=datetime.fromisoformat, + help="with --dry-run: decide as of this local instant", + ) + return parser + + +def parse_with_now( + parser: argparse.ArgumentParser, + argv: list[str] | None, + now: Callable[[], datetime], +) -> tuple[argparse.Namespace, datetime]: + """Parse ``argv`` (default ``sys.argv``) and resolve the instant to use.""" + args = parser.parse_args(sys.argv[1:] if argv is None else argv) + if args.at is not None and not args.dry_run: + parser.error("--at only goes with --dry-run") + return args, now() if args.at is None else args.at.astimezone() diff --git a/wake_alarm/_constants.py b/wake_alarm/_constants.py index 58ab60c..5d16f7a 100644 --- a/wake_alarm/_constants.py +++ b/wake_alarm/_constants.py @@ -7,7 +7,7 @@ say, and has the morning routine already started today. from __future__ import annotations -from datetime import timedelta +from datetime import date, timedelta from pathlib import Path # Days the wake alarm is active (Python weekday(): Mon=0 ... Sun=6) @@ -161,3 +161,52 @@ SESSION_NONE_GRACE: timedelta = timedelta(minutes=15) # Workday stick (a Tue/Wed/Thu miss -> tomorrow loses its leniency). The days # themselves are freedays.WORKDAYS, the one set every gate app reads. WORKDAY_PENALTY_FILE: Path = LOG_STATE_DIR / "workday_penalty.json" + + +# --- Wake enforcement v2 (DOCS-wake-enforcement.md is the contract) ----------- +# These names are read by test_stakes_mirrors.py against the phone's Dart +# constants: rename one here and there in the same commit. + +# PC fallback alarm, all relative to today's alarm A (clamped <= 08:00): +# ring when the phone has not opened its ring screen by A + 2 min, stop after +# 30 min of ringing, and never start a ring past A + 45 min. +FALLBACK_PHONE_SILENT_AFTER: timedelta = timedelta(minutes=2) +FALLBACK_RING_CAP: timedelta = timedelta(minutes=30) +FALLBACK_WINDOW_END: timedelta = timedelta(minutes=45) + +# The PC arms its RTC this long before a stick-day alarm, so it is up (resumed +# from hibernate, or booted) and has synced by the time the phone rings. +RTC_WAKE_LEAD: timedelta = timedelta(minutes=10) + +# Phone stick: a streak of n consecutive stick-day misses locks today plus +# min(n, 4) days -- five calendar days at most. +STICK_LOCKDOWN_MAX_DAYS: int = 5 + +# The "That was a bug" escape hatch is honoured for mornings up to and +# including this date, and only with a reason at least this long. +ESCAPE_HATCH_LAST_DAY: date = date(2026, 10, 15) +ESCAPE_REASON_MIN_CHARS: int = 40 + +# Today's fallback-ring latch: {"date", "started_at", "stopped_at", "reason", +# "stop_reason"}. +FALLBACK_RING_FILE: Path = LOG_STATE_DIR / "fallback_ring.json" + +# {"mode": "hibernate" | "poweroff"}; how the PC goes down for the night. +PC_WAKE_CONFIG: Path = Path.home() / ".config" / "wake_alarm" / "pc_wake.json" + +# The daily audit's reports. Outside the repo on purpose: personal data. +INCIDENTS_DIR: Path = Path.home() / "data" / "wake-alarm" / "incidents" + +# The RTC alarm _rtc last armed ({"epoch", "target_date"}), so a disarm +# only ever clears an alarm this package set. +RTC_ARMED_FILE: Path = LOG_STATE_DIR / "rtc_armed.json" + +# Last value logged per change-only event (fallback_tick, rtc_armed, ...), so +# a per-minute or per-15-minute job logs a decision once, not every run. +EVENT_STATE_FILE: Path = LOG_STATE_DIR / "event_state.json" + +# crdt_sync field names on alarm.json for the v2 fields (phone writes them). +RING_STARTED_FIELD: str = "ring_started" +ESCAPES_FIELD: str = "escapes" +STICK_STATE_FIELD: str = "stick_state" +PHONE_EVENTS_FIELD: str = "phone_events" diff --git a/wake_alarm/_enforcement_payload.py b/wake_alarm/_enforcement_payload.py new file mode 100644 index 0000000..1ba606d --- /dev/null +++ b/wake_alarm/_enforcement_payload.py @@ -0,0 +1,165 @@ +"""Merge the v2 enforcement fields across every device's ``alarm.json``. + +Pure: ``(device_id, raw_json)`` pairs in -- the same list +``_sync_transport.fetch_firebase_logs`` returns and ``_session_payload`` +reads -- one :class:`EnforcementView` out. Every field is a crdt_sync +``(value, HLC)`` pair: + +* ``ring_started`` and ``stick_state`` describe *now*, so the newest HLC wins + (``device_id`` breaks an exact tie, so two installs never alternate); +* ``escapes`` and ``phone_events`` are history, so they are the **union**: + newest-wins on a list would let a reinstalled phone, publishing an empty + history, erase every escape and event the old install recorded. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +import json +import logging +from typing import TYPE_CHECKING, Any + +from wake_alarm._alarm_payload import extract_hlc +from wake_alarm._constants import ( + ESCAPE_HATCH_LAST_DAY, + ESCAPE_REASON_MIN_CHARS, + ESCAPES_FIELD, + PHONE_EVENTS_FIELD, + RING_STARTED_FIELD, + STICK_STATE_FIELD, +) +from wake_alarm._enforcement_records import ( + Escape, + PhoneEvent, + RingStarted, + StickState, + escape_from, + phone_event_from, + ring_started_from, + stick_state_from, +) + +if TYPE_CHECKING: + from collections.abc import Callable + +_logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class EnforcementView: + """What every device together says about the enforcement fields.""" + + ring_started: RingStarted | None = None + escapes: tuple[Escape, ...] = () + stick_state: StickState | None = None + phone_events: tuple[PhoneEvent, ...] = () + + def ring_started_on(self, day: str) -> bool: + """Whether the phone's ring screen opened on ``day``.""" + return self.ring_started is not None and self.ring_started.date == day + + def honoured_escapes(self) -> tuple[Escape, ...]: + """Escapes the PC acts on; see :func:`is_honoured`.""" + return tuple(e for e in self.escapes if is_honoured(e)) + + def escape_for(self, day: str) -> Escape | None: + """The first honoured escape excusing ``day``, or None.""" + return next((e for e in self.honoured_escapes() if e.date == day), None) + + def escaped_dates(self) -> frozenset[str]: + """Every morning an honoured escape excuses.""" + return frozenset(e.date for e in self.honoured_escapes()) + + +def is_honoured(escape: Escape) -> bool: + """An escape counts only inside the hatch's life and with a real reason. + + The phone hides the button after ``ESCAPE_HATCH_LAST_DAY`` and refuses a + short reason; the PC checks both again so a stale or hand-edited record + cannot cancel a penalty the phone itself would not have excused. + """ + try: + day = date.fromisoformat(escape.date) + except ValueError: + return False + return ( + day <= ESCAPE_HATCH_LAST_DAY + and len(escape.reason.strip()) >= ESCAPE_REASON_MIN_CHARS + ) + + +def _pair_value(log: dict[str, Any], field: str) -> object: + """The value half of one ``(value, HLC)`` pair, or None.""" + record = log.get("alarm") + fields = record.get("fields") if isinstance(record, dict) else None + pair = fields.get(field) if isinstance(fields, dict) else None + return pair[0] if isinstance(pair, list) and pair else None + + +def _newest[T]( + logs: list[tuple[str, dict[str, Any]]], + field: str, + build: Callable[[object], T | None], +) -> T | None: + """The parseable value with the newest ``(HLC, device_id)``, or None.""" + ranked: list[tuple[tuple[str, str], T]] = [] + for device_id, log in logs: + built = build(_pair_value(log, field)) + if built is not None: + ranked.append(((extract_hlc(log, field), device_id), built)) + return max(ranked, key=lambda entry: entry[0])[1] if ranked else None + + +def _union[T]( + logs: list[tuple[str, dict[str, Any]]], + field: str, + build: Callable[[object], T | None], + identity: Callable[[T], str], +) -> tuple[T, ...]: + """Every parseable list entry across devices, first occurrence wins.""" + seen: set[str] = set() + merged: list[T] = [] + for _, log in sorted( + logs, key=lambda entry: extract_hlc(entry[1], field), reverse=True + ): + value = _pair_value(log, field) + for raw in value if isinstance(value, list) else []: + built = build(raw) + if built is None or identity(built) in seen: + continue + seen.add(identity(built)) + merged.append(built) + return tuple(merged) + + +def _event_identity(event: PhoneEvent) -> str: + """Two devices reporting the same event at the same instant are one.""" + return json.dumps([event.at, event.kind, event.data], sort_keys=True, default=str) + + +def parse_logs(published: list[tuple[str, str]]) -> list[tuple[str, dict[str, Any]]]: + """``(device_id, parsed_log)`` for every log that is a JSON object.""" + logs: list[tuple[str, dict[str, Any]]] = [] + for device_id, raw in published: + try: + log = json.loads(raw) + except json.JSONDecodeError: + _logger.info("Ignoring unparsable enforcement log from %r", device_id) + continue + if isinstance(log, dict): + logs.append((device_id, log)) + return logs + + +def enforcement_view(published: list[tuple[str, str]]) -> EnforcementView: + """Merge every device's v2 fields into one view.""" + logs = parse_logs(published) + return EnforcementView( + ring_started=_newest(logs, RING_STARTED_FIELD, ring_started_from), + escapes=_union(logs, ESCAPES_FIELD, escape_from, lambda e: e.id), + stick_state=_newest(logs, STICK_STATE_FIELD, stick_state_from), + phone_events=_union( + logs, PHONE_EVENTS_FIELD, phone_event_from, _event_identity + ), + ) diff --git a/wake_alarm/_enforcement_records.py b/wake_alarm/_enforcement_records.py new file mode 100644 index 0000000..0f48e78 --- /dev/null +++ b/wake_alarm/_enforcement_records.py @@ -0,0 +1,127 @@ +"""One phone value in, one typed record out: the v2 fields' shapes. + +Pure, like ``_session_payload``. Every builder returns None (or drops the +entry) on anything malformed instead of raising, and never turns a JSON +``null`` into the string ``"None"`` -- the exact bug that hid six people's +replies in signal-bot. Shapes are fixed by ``DOCS-wake-enforcement.md``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime + + +@dataclass(frozen=True) +class RingStarted: + """The phone opened its ring screen on ``date`` at ``at``.""" + + date: str + at: str + + +@dataclass(frozen=True) +class Escape: + """One use of the "That was a bug" hatch; ``date`` is the excused morning.""" + + id: str + date: str + at: str + reason: str + + +@dataclass(frozen=True) +class StickState: + """The phone's consecutive-miss streak and its current lockdown.""" + + streak: int + locked_through: str | None + last_miss: str | None + + +@dataclass(frozen=True) +class PhoneEvent: + """One entry of the phone's own event log.""" + + at: str + kind: str + data: dict[str, object] = field(default_factory=dict) + + +def text(value: object) -> str: + """A string field, ``""`` for null or any non-string.""" + return value if isinstance(value, str) else "" + + +def optional_text(value: object) -> str | None: + """A non-empty string, else None (null stays null).""" + return value if isinstance(value, str) and value else None + + +def is_iso_date(value: str) -> bool: + """Whether ``value`` is a ``YYYY-MM-DD`` date.""" + try: + date.fromisoformat(value) + except ValueError: + return False + return len(value) == len("YYYY-MM-DD") + + +def parse_instant(value: str) -> datetime | None: + """An ISO-8601 instant as an aware local datetime; naive means local.""" + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + return parsed.astimezone() + + +def ring_started_from(value: object) -> RingStarted | None: + """``{"date", "at"}`` or None.""" + if not isinstance(value, dict): + return None + day, at = text(value.get("date")), text(value.get("at")) + if not is_iso_date(day): + return None + return RingStarted(date=day, at=at) + + +def escape_from(value: object) -> Escape | None: + """``{"id", "date", "at", "reason"}`` or None when id/date are unusable.""" + if not isinstance(value, dict): + return None + ident, day = text(value.get("id")), text(value.get("date")) + if not ident or not is_iso_date(day): + return None + return Escape( + id=ident, date=day, at=text(value.get("at")), reason=text(value.get("reason")) + ) + + +def stick_state_from(value: object) -> StickState | None: + """``{"streak", "locked_through", "last_miss"}`` or None.""" + if not isinstance(value, dict): + return None + streak = value.get("streak") + if isinstance(streak, bool) or not isinstance(streak, int) or streak < 0: + return None + return StickState( + streak=streak, + locked_through=optional_text(value.get("locked_through")), + last_miss=optional_text(value.get("last_miss")), + ) + + +def phone_event_from(value: object) -> PhoneEvent | None: + """``{"at", "kind", "data"}`` or None when ``at``/``kind`` are missing.""" + if not isinstance(value, dict): + return None + at, kind = text(value.get("at")), text(value.get("kind")) + if not at or not kind: + return None + data = value.get("data") + return PhoneEvent( + at=at, + kind=kind, + data={str(k): v for k, v in data.items()} if isinstance(data, dict) else {}, + ) diff --git a/wake_alarm/_escapes.py b/wake_alarm/_escapes.py new file mode 100644 index 0000000..397608f --- /dev/null +++ b/wake_alarm/_escapes.py @@ -0,0 +1,48 @@ +"""React to the phone's escape hatch on the PC side. + +An honoured escape (``_enforcement_payload.is_honoured``) for morning ``D``: + +* is logged once as ``escape_seen`` -- the seen ids live in the event state, + pruned to the ids the phone still carries, so the set stays bounded; +* cancels a ``workday_penalty.json`` issued on ``D`` (``penalty_cancelled``). + Checked on every refresh, not only on ``D`` itself: an escape for Thursday + typed on Thursday evening must still cancel the penalty Friday would read. + +The fallback alarm and ``_workday_penalty.maybe_apply`` consult the escape +dates themselves; this module only does the bookkeeping both share. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from wake_alarm._events import emit, last_value, remember +from wake_alarm._workday_penalty import cancel_for_escapes + +if TYPE_CHECKING: + from wake_alarm._enforcement_payload import EnforcementView + +_SEEN_SLOT = "escape_seen" + + +def note_escapes(view: EnforcementView) -> list[str]: + """Log newly seen escapes and cancel their penalties; returns new ids.""" + seen = set((last_value(_SEEN_SLOT) or "").split(",")) - {""} + honoured = view.honoured_escapes() + fresh: list[str] = [] + for escape in honoured: + if escape.id in seen: + continue + emit( + "escape_seen", + f"escape for {escape.date}", + id=escape.id, + date=escape.date, + at=escape.at, + reason=escape.reason, + ) + fresh.append(escape.id) + current = {escape.id for escape in honoured} + remember(_SEEN_SLOT, ",".join(sorted(current)) or None) + cancel_for_escapes(view.escaped_dates()) + return fresh diff --git a/wake_alarm/_events.py b/wake_alarm/_events.py new file mode 100644 index 0000000..9fb1022 --- /dev/null +++ b/wake_alarm/_events.py @@ -0,0 +1,83 @@ +"""Named PC events for ``events.jsonl`` -- the audit's half of the timeline. + +Every event in ``DOCS-wake-enforcement.md`` goes through :func:`emit`, which +logs one INFO record carrying a top-level ``event`` key and a ``fields`` +object; the JSONL sink (``_log_sink``) turns both into keys of the line, so +the audit can select on ``.event`` with no message parsing. + +Jobs that run every minute (``fallback_tick``) or every 15 minutes +(``rtc_armed``) must log a decision once, not on every run. The shared +``DedupeFilter`` remembers a single fingerprint for everything, so two such +streams interleaving would defeat it; :func:`emit_on_change` instead keeps +the last value per event name in ``EVENT_STATE_FILE``. +""" + +from __future__ import annotations + +import json +import logging + +from wake_alarm._constants import EVENT_STATE_FILE + +_logger = logging.getLogger("wake_alarm.events") + + +def emit(event: str, message: str, **fields: object) -> None: + """Log ``event`` with ``fields`` as one structured INFO record.""" + _logger.info( + "%s: %s", event, message, extra={"event": event, "fields": dict(fields)} + ) + + +def _read_state() -> dict[str, str]: + """The persisted last-logged values; empty when absent or unreadable.""" + try: + raw = json.loads(EVENT_STATE_FILE.read_text(encoding="utf-8")) + except OSError, ValueError: + return {} + if not isinstance(raw, dict): + return {} + return {k: v for k, v in raw.items() if isinstance(v, str)} + + +def _write_state(state: dict[str, str]) -> None: + """Persist ``state`` atomically; a failure only costs a repeated line.""" + tmp = EVENT_STATE_FILE.with_name(EVENT_STATE_FILE.name + ".tmp") + try: + EVENT_STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp.write_text(json.dumps(state, sort_keys=True), encoding="utf-8") + tmp.replace(EVENT_STATE_FILE) + except OSError as exc: + _logger.info("Cannot persist %s: %s", EVENT_STATE_FILE, exc) + + +def last_value(slot: str) -> str | None: + """What was last remembered under ``slot``, or None.""" + return _read_state().get(slot) + + +def remember(slot: str, value: str | None) -> None: + """Store ``value`` under ``slot`` (None forgets it).""" + state = _read_state() + if value is None: + if slot not in state: + return + del state[slot] + elif state.get(slot) == value: + return + else: + state[slot] = value + _write_state(state) + + +def emit_on_change(event: str, key: str, message: str, **fields: object) -> bool: + """Emit ``event`` only when ``key`` differs from the last one logged. + + Returns: + True when the event was logged. + """ + if last_value(event) == key: + return False + emit(event, message, **fields) + remember(event, key) + return True diff --git a/wake_alarm/_fallback.py b/wake_alarm/_fallback.py new file mode 100644 index 0000000..343b4ee --- /dev/null +++ b/wake_alarm/_fallback.py @@ -0,0 +1,179 @@ +"""The PC fallback alarm: one per-minute tick, 04:00-09:59. + +``wake-alarm-fallback.timer`` runs ``--tick`` every minute. Outside +``A - 5 min .. A + 45 min`` (or on a free day) a tick decides from local +state alone and never touches the network. Inside, it reads every device's +``alarm.json`` once, decides (``_fallback_policy.decide``) and, when the +decision is to ring, rings *in this process* until a stop condition holds +-- the unit is a oneshot, so the timer's next minute is a no-op while it +rings and no PID file is needed. Firebase unreachable counts as "no ping, +no session, no escape": fail toward ringing. + + python -m wake_alarm._fallback --tick # what the timer runs + python -m wake_alarm._fallback --dry-run # decide now, change nothing + python -m wake_alarm._fallback --show # latch + last decision, offline +""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import UTC, datetime +import signal +import sys +from typing import TYPE_CHECKING + +import freedays + +from wake_alarm._cli_modes import mode_parser, parse_with_now +from wake_alarm._events import emit, emit_on_change, last_value +from wake_alarm._fallback_latch import Latch, mark_started, mark_stopped, read_latch +from wake_alarm._fallback_observe import ( + Observation, + observe, + record, + remember_final, + settled_reason, +) +from wake_alarm._fallback_policy import ( + Decision, + Facts, + alarm_instant, + decide, + stop_reason, +) +from wake_alarm._fallback_ring import ring +from wake_alarm._logging_setup import configure_logging +from wake_alarm._ring_wav import SYSTEM_WAV_DIR, USER_WAV_DIR, ensure_pair +from wake_alarm._sync import get_alarm + +if TYPE_CHECKING: + from types import FrameType + +TERMINATED = "terminated" + + +def _now() -> datetime: + """Aware local now; the seam tests patch.""" + return datetime.now(tz=UTC).astimezone() + + +def _close(latch: Latch, reason: str) -> None: + """Stop an open latch and log it.""" + stopped = mark_stopped(latch, now=_now(), stop_reason=reason) + emit( + "fallback_ring_stop", + reason, + stop_reason=reason, + started_at=stopped.started_at, + stopped_at=stopped.stopped_at, + ) + + +def _poll(day: str) -> str | None: + """Re-observe while ringing; the reason to stop, or None.""" + seen = observe(_now()) + record(seen) + reason = stop_reason(replace(seen.facts, latch=read_latch(day))) + if reason is not None: + remember_final(day, reason) + return reason + + +def ring_now(facts: Facts, reason: str) -> str: + """Latch, ring until a stop condition, unlatch; returns the stop reason.""" + day = facts.now.date().isoformat() + resumed = facts.latch is not None and facts.latch.started_at is not None + latch = mark_started(facts.latch, day=day, now=facts.now, reason=reason) + emit( + "fallback_ring_start", + reason, + reason=reason, + resumed=resumed, + alarm=facts.alarm_at.isoformat(), + started_at=latch.started_at, + ) + stopped = TERMINATED + try: + # Inside the try: a WAV that cannot be generated must still close + # the latch (as "terminated"), never leave it open. + rise, loop = ensure_pair(SYSTEM_WAV_DIR, USER_WAV_DIR) + stopped = ring(lambda: _poll(day), rise=rise, loop=loop) + finally: + _close(latch, stopped) + return stopped + + +def tick(now: datetime) -> Decision: + """One timer tick: observe, log a changed decision, ring if due.""" + seen = observe(now) + record(seen) + facts, day = seen.facts, now.date().isoformat() + decision = decide(facts) + emit_on_change( + "fallback_tick", + f"{day}|{decision.ring}|{decision.reason}", + f"{'ring' if decision.ring else 'quiet'}: {decision.reason}", + ring=decision.ring, + reason=decision.reason, + alarm=facts.alarm_at.isoformat(), + reachable=seen.reachable, + ) + remember_final(day, decision.reason) + latch = facts.latch + if decision.ring: + ring_now(facts, decision.reason) + elif latch is not None and latch.started_at and not latch.stopped_at: + _close(latch, decision.reason) + return decision + + +def describe(seen: Observation, decision: Decision) -> str: + """The ``--dry-run`` report.""" + facts = seen.facts + verdict = "WOULD RING" if decision.ring else "quiet" + return ( + f"{facts.now:%Y-%m-%d %H:%M} — {verdict}: {decision.reason}\n" + f" alarm A={facts.alarm_at:%H:%M} free_day={facts.free_day} " + f"firebase={'ok' if seen.reachable else 'UNREACHABLE'}\n" + f" escaped={facts.escaped} session_started={facts.session_started} " + f"ring_started={facts.ring_started} settled={facts.settled}\n" + f" latch={facts.latch}\n" + ) + + +def show(now: datetime) -> str: + """The ``--show`` report: local state only, no network.""" + day = now.date().isoformat() + alarm_at = alarm_instant(get_alarm(), now.date()) + return ( + f"today {day}: A={alarm_at:%H:%M} " + f"free_day={freedays.is_free_day(now.date())}\n" + f" latch={read_latch(day)}\n" + f" last fallback_tick={last_value('fallback_tick')}\n" + f" settled={settled_reason(day, read_latch(day))}\n" + ) + + +def _terminate(signum: int, _frame: FrameType | None) -> None: + """SIGTERM -> SystemExit, so ``finally`` silences the speakers.""" + raise SystemExit(128 + signum) + + +def main(argv: list[str] | None = None) -> int: + """Entry point.""" + parser = mode_parser("PC fallback alarm tick", "tick", "decide and act") + args, now = parse_with_now(parser, argv, _now) + if args.show: + sys.stdout.write(show(now)) + elif args.dry_run: + seen = observe(now) + sys.stdout.write(describe(seen, decide(seen.facts))) + else: + configure_logging(component="fallback") + signal.signal(signal.SIGTERM, _terminate) + tick(now) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/wake_alarm/_fallback_latch.py b/wake_alarm/_fallback_latch.py new file mode 100644 index 0000000..9d5b928 --- /dev/null +++ b/wake_alarm/_fallback_latch.py @@ -0,0 +1,88 @@ +"""Today's fallback-ring latch: ``fallback_ring.json``. + +``{"date", "started_at", "stopped_at", "reason", "stop_reason"}`` -- the +contract's shape. It is what makes the 30-minute cap survive the ringing +process dying (a hibernate, a reboot): the cap is measured from the +wall-clock ``started_at``, never a monotonic clock, which does not advance +through S4. An unreadable latch is no latch -- fail toward ringing. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, replace +import json +import logging +from typing import TYPE_CHECKING + +from wake_alarm._constants import FALLBACK_RING_FILE +from wake_alarm._enforcement_records import optional_text, parse_instant + +if TYPE_CHECKING: + from datetime import datetime + +_logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Latch: + """One day's fallback ring, as far as it got.""" + + date: str + started_at: str | None = None + stopped_at: str | None = None + reason: str | None = None + stop_reason: str | None = None + + def started(self) -> datetime | None: + """``started_at`` as an aware datetime, or None.""" + return parse_instant(self.started_at) if self.started_at else None + + +def read_latch(day: str) -> Latch | None: + """The latch for ``day``; None when absent, unreadable or another day's.""" + try: + raw = json.loads(FALLBACK_RING_FILE.read_text(encoding="utf-8")) + except FileNotFoundError: + return None + except (OSError, ValueError) as exc: + _logger.info("Ignoring unreadable %s: %s", FALLBACK_RING_FILE, exc) + return None + if not isinstance(raw, dict) or raw.get("date") != day: + return None + return Latch( + date=day, + started_at=optional_text(raw.get("started_at")), + stopped_at=optional_text(raw.get("stopped_at")), + reason=optional_text(raw.get("reason")), + stop_reason=optional_text(raw.get("stop_reason")), + ) + + +def write_latch(latch: Latch) -> bool: + """Persist ``latch``; False when the disk refused.""" + try: + FALLBACK_RING_FILE.parent.mkdir(parents=True, exist_ok=True) + FALLBACK_RING_FILE.write_text(json.dumps(asdict(latch), indent=2)) + except OSError as exc: + _logger.warning("Could not write %s: %s", FALLBACK_RING_FILE, exc) + return False + return True + + +def mark_started( + previous: Latch | None, *, day: str, now: datetime, reason: str +) -> Latch: + """Record a ring (re)starting; a resumed ring keeps its first start.""" + if previous is not None and previous.started_at: + latch = replace(previous, stopped_at=None, stop_reason=None) + else: + latch = Latch(date=day, started_at=now.isoformat(), reason=reason) + write_latch(latch) + return latch + + +def mark_stopped(latch: Latch, *, now: datetime, stop_reason: str) -> Latch: + """Record why and when the ring stopped.""" + stopped = replace(latch, stopped_at=now.isoformat(), stop_reason=stop_reason) + write_latch(stopped) + return stopped diff --git a/wake_alarm/_fallback_observe.py b/wake_alarm/_fallback_observe.py new file mode 100644 index 0000000..d8bf9ba --- /dev/null +++ b/wake_alarm/_fallback_observe.py @@ -0,0 +1,100 @@ +"""One look at the world for the fallback alarm, and its bookkeeping. + +Split from ``_fallback`` (tick, ring lifecycle, CLI): this is "what is true +right now" -- today's alarm, the latch, the free-day pool and, only when the +decision needs it, every device's ``alarm.json``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING + +import freedays + +from wake_alarm._enforcement_payload import EnforcementView, enforcement_view +from wake_alarm._escapes import note_escapes +from wake_alarm._events import emit_on_change, last_value, remember +from wake_alarm._fallback_latch import Latch, read_latch +from wake_alarm._fallback_policy import ( + FINAL_REASONS, + Facts, + alarm_instant, + needs_facts, +) +from wake_alarm._session_payload import newest_session +from wake_alarm._sync import get_alarm +from wake_alarm._sync_transport import fetch_firebase_logs + +if TYPE_CHECKING: + from datetime import datetime + +SETTLED_SLOT = "fallback_settled" + + +@dataclass(frozen=True) +class Observation: + """One look at the world: the facts, and whether Firebase answered.""" + + facts: Facts + reachable: bool = True + view: EnforcementView | None = None + + +def settled_reason(day: str, latch: Latch | None) -> str | None: + """A final reason already seen today, from the latch or the event state.""" + if latch is not None and latch.stop_reason in FINAL_REASONS: + return latch.stop_reason + remembered = (last_value(SETTLED_SLOT) or "").partition("|") + return remembered[2] if remembered[0] == day and remembered[2] else None + + +def observe(now: datetime) -> Observation: + """Gather the facts; the network only when the decision needs it.""" + day = now.date().isoformat() + latch = read_latch(day) + facts = Facts( + now=now, + alarm_at=alarm_instant(get_alarm(), now.date()), + free_day=freedays.is_free_day(now.date()), + latch=latch, + settled=settled_reason(day, latch), + ) + if facts.free_day or not needs_facts(now, facts.alarm_at, latch): + return Observation(facts) + published = fetch_firebase_logs() + if published is None: + return Observation(facts, reachable=False) + view = enforcement_view(published) + session = newest_session(published) + return Observation( + replace( + facts, + escaped=view.escape_for(day) is not None, + session_started=session is not None and session.date == day, + ring_started=view.ring_started_on(day), + ), + view=view, + ) + + +def record(seen: Observation) -> None: + """Log what this observation means for the PC's own state.""" + day = seen.facts.now.date().isoformat() + if seen.reachable: + remember("firebase_unreachable", None) + else: + emit_on_change( + "firebase_unreachable", + day, + "treating as no ping, no session", + alarm=seen.facts.alarm_at.isoformat(), + ) + if seen.view is not None: + note_escapes(seen.view) + + +def remember_final(day: str, reason: str) -> None: + """Make a final reason stick for the rest of the day.""" + if reason in FINAL_REASONS: + remember(SETTLED_SLOT, f"{day}|{reason}") diff --git a/wake_alarm/_fallback_policy.py b/wake_alarm/_fallback_policy.py new file mode 100644 index 0000000..13ff530 --- /dev/null +++ b/wake_alarm/_fallback_policy.py @@ -0,0 +1,159 @@ +"""The PC fallback alarm's rule, as one pure function. + +``DOCS-wake-enforcement.md``'s table, first match wins, inside +``A - 5 min .. A + 45 min`` on a day that is not free: + +========================================================= ================== +escape recorded for today, or a session started today do not ring / stop +ring already ran 30 min today (``FALLBACK_RING_CAP``) never again today +``now >= A + 2 min`` and no ``ring_started`` for today ring: phone_silent +``now >= A + 15 min`` (``SESSION_NONE_GRACE``) ring: no_session +========================================================= ================== + +The window gates *starting* a ring only. A ring that already started today +and has not hit its cap is judged by the table alone, so a ringing process +killed by a hibernate resumes after the resume instead of going quiet +because the clock moved past the window. Firebase being unreachable is +modelled by the caller as "no ping, no session, no escape": fail toward +ringing. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, time, timedelta +from typing import TYPE_CHECKING + +from wake_alarm._constants import ( + FALLBACK_ALARM_HOUR, + FALLBACK_PHONE_SILENT_AFTER, + FALLBACK_RING_CAP, + FALLBACK_WINDOW_END, + SESSION_NONE_GRACE, +) + +if TYPE_CHECKING: + from wake_alarm._fallback_latch import Latch + from wake_alarm._sync import Alarm + +# The tick starts looking (and fetching) this long before the alarm, so the +# first decision of the day is logged before anything can ring. +FALLBACK_WINDOW_START: timedelta = timedelta(minutes=5) + +# While ringing, how often the stop conditions are re-read from Firebase. +FALLBACK_POLL_SECONDS: float = 20.0 + +# Reasons, as they appear in events.jsonl and fallback_ring.json. +FREE_DAY = "free_day" +OUTSIDE_WINDOW = "outside_window" +ESCAPE = "escape" +SESSION_STARTED = "session_started" +CAP = "cap" +PHONE_SILENT = "phone_silent" +NO_SESSION = "no_session" +WAITING = "waiting" + +# Once seen, these hold for the rest of the day even if Firebase later stops +# answering: an unreachable backend fails toward ringing, but never toward +# ringing at someone already known to be up. +FINAL_REASONS: frozenset[str] = frozenset({ESCAPE, SESSION_STARTED, CAP}) + + +@dataclass(frozen=True) +class Facts: + """Everything one decision needs; the caller gathers it. + + ``settled`` is a final reason (``FINAL_REASONS``) already seen today. + """ + + now: datetime + alarm_at: datetime + free_day: bool + escaped: bool = False + session_started: bool = False + ring_started: bool = False + latch: Latch | None = None + settled: str | None = None + + +@dataclass(frozen=True) +class Decision: + """Ring or not, and the one-word reason either way.""" + + ring: bool + reason: str + + +def alarm_instant(alarm: Alarm, day: date) -> datetime: + """Today's ``A``: the alarm on ``day``, clamped to the 08:00 baseline. + + Built naive and localised afterwards, like ``_trigger._fire_window``, so + the offset is the one in force at the alarm, not at the tick. + """ + hour, minute = min((alarm.hour, alarm.minute), (FALLBACK_ALARM_HOUR, 0)) + return datetime.combine(day, time(hour, minute)).astimezone() + + +def _ring_started_today(latch: Latch | None) -> bool: + """Whether today's fallback has already rung at all.""" + return latch is not None and latch.started() is not None + + +def needs_facts(now: datetime, alarm_at: datetime, latch: Latch | None) -> bool: + """Whether this tick must fetch the phone's state at all. + + Outside the window with nothing rung today the answer is already + ``outside_window``, so the per-minute tick stays off the network. + """ + if _ring_started_today(latch): + return True + return alarm_at - FALLBACK_WINDOW_START <= now < alarm_at + FALLBACK_WINDOW_END + + +def is_capped(facts: Facts) -> bool: + """Whether today's ring has used its 30 minutes.""" + latch = facts.latch + if latch is None: + return False + if latch.stop_reason == CAP: + return True + started = latch.started() + return started is not None and facts.now >= started + FALLBACK_RING_CAP + + +def stop_reason(facts: Facts) -> str | None: + """Why a ring in progress must stop now, or None to keep ringing. + + Only the contract's three stop conditions (plus a day that turned free): + a phone that comes back on mid-ring does not silence the PC -- getting up + does. + """ + if facts.free_day: + return FREE_DAY + if facts.settled in FINAL_REASONS: + return facts.settled + if facts.escaped: + return ESCAPE + if facts.session_started: + return SESSION_STARTED + if is_capped(facts): + return CAP + return None + + +def decide(facts: Facts) -> Decision: + """The table above; first match wins.""" + if facts.free_day: + return Decision(ring=False, reason=FREE_DAY) + if not needs_facts(facts.now, facts.alarm_at, facts.latch): + return Decision(ring=False, reason=OUTSIDE_WINDOW) + stop = stop_reason(facts) + if stop is not None: + return Decision(ring=False, reason=stop) + if facts.now >= facts.alarm_at + FALLBACK_PHONE_SILENT_AFTER and not ( + facts.ring_started + ): + return Decision(ring=True, reason=PHONE_SILENT) + if facts.now >= facts.alarm_at + SESSION_NONE_GRACE: + return Decision(ring=True, reason=NO_SESSION) + return Decision(ring=False, reason=WAITING) diff --git a/wake_alarm/_fallback_ring.py b/wake_alarm/_fallback_ring.py new file mode 100644 index 0000000..a1c167e --- /dev/null +++ b/wake_alarm/_fallback_ring.py @@ -0,0 +1,108 @@ +"""Ring until told to stop: the loop behind one fallback ring. + +Owns only the noise and its lifetime. *Whether* to keep ringing is the +caller's ``poll`` (re-read every ``FALLBACK_POLL_SECONDS``), which returns a +stop reason or None. The backend is always stopped on the way out, however +the loop ends -- including the ``SystemExit`` a SIGTERM handler raises -- so +a dying tick can never leave the root ALSA unit playing (and the unit has its +own runtime cap for a tick that is SIGKILLed). + +Cadence uses the monotonic clock; the 30-minute cap is the caller's, on the +wall clock, because the monotonic clock does not advance through S4. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import time +from typing import TYPE_CHECKING + +from wake_alarm._events import emit +from wake_alarm._fallback_policy import FALLBACK_POLL_SECONDS +from wake_alarm._ring_player import AlsaUnitBackend, Backend, select_backend + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + +BACKEND_FAILED = "backend_failed" +_PUMP_SECONDS = 1.0 + + +def _start(rise: Path, loop: Path) -> Backend | None: + """Pick and start a backend, falling back to ALSA if PipeWire refuses.""" + backend, why = select_backend(rise, loop) + started, detail = backend.start() + if not started and backend.name != AlsaUnitBackend.name: + why = f"{why}; {backend.name} failed: {detail}" + backend = AlsaUnitBackend() + started, detail = backend.start() + emit( + "ring_backend", + f"{backend.name} ({'started' if started else 'FAILED'})", + backend=backend.name, + why=why, + started=started, + detail=detail, + ) + return backend if started else None + + +def _switch_to_alsa(failed: Backend) -> Backend | None: + """PipeWire died mid-ring: silence it and hand over to the root unit.""" + failed.stop() + if failed.name == AlsaUnitBackend.name: + return None + backend = AlsaUnitBackend() + started, detail = backend.start() + emit( + "ring_backend", + f"alsa after {failed.name} died ({'started' if started else 'FAILED'})", + backend=backend.name, + why=f"{failed.name} stopped playing mid-ring", + started=started, + detail=detail, + ) + return backend if started else None + + +@dataclass(frozen=True) +class Cadence: + """How the loop keeps time; injectable so tests never really wait.""" + + clock: Callable[[], float] = time.monotonic + sleep: Callable[[float], None] = time.sleep + poll_seconds: float = FALLBACK_POLL_SECONDS + + +def ring( + poll: Callable[[], str | None], + *, + rise: Path, + loop: Path, + cadence: Cadence | None = None, +) -> str: + """Ring until ``poll`` names a stop reason; returns that reason. + + Returns ``backend_failed`` when no backend could make a sound. + """ + tick = cadence or Cadence() + backend = _start(rise, loop) + if backend is None: + return BACKEND_FAILED + try: + next_poll = tick.clock() + tick.poll_seconds + while True: + if not backend.pump(): + replacement = _switch_to_alsa(backend) + if replacement is None: + return BACKEND_FAILED + backend = replacement + if tick.clock() >= next_poll: + reason = poll() + if reason is not None: + return reason + next_poll = tick.clock() + tick.poll_seconds + tick.sleep(_PUMP_SECONDS) + finally: + backend.stop() diff --git a/wake_alarm/_pc_wake_mode.py b/wake_alarm/_pc_wake_mode.py new file mode 100644 index 0000000..80de00a --- /dev/null +++ b/wake_alarm/_pc_wake_mode.py @@ -0,0 +1,51 @@ +"""How the PC goes down for the night: ``hibernate`` (default) or ``poweroff``. + +``~/.config/wake_alarm/pc_wake.json`` ``{"mode": "hibernate" | "poweroff"}``. +In ``hibernate`` mode ``install.sh`` installs a logind drop-in that makes the +power key hibernate -- verified on this host to resume from the RTC alarm. +``poweroff`` relies on the firmware waking from S5 and changes nothing. + +Anything missing or unreadable is ``hibernate``: the verified path. + + python -m wake_alarm._pc_wake_mode # prints the mode (install.sh reads it) +""" + +from __future__ import annotations + +import json +import logging +import sys + +from wake_alarm._constants import PC_WAKE_CONFIG + +_logger = logging.getLogger(__name__) + +HIBERNATE = "hibernate" +POWEROFF = "poweroff" +MODES = frozenset({HIBERNATE, POWEROFF}) + + +def read_mode() -> str: + """The configured mode, ``hibernate`` unless the file says otherwise.""" + try: + raw = json.loads(PC_WAKE_CONFIG.read_text(encoding="utf-8")) + except FileNotFoundError: + return HIBERNATE + except (OSError, ValueError) as exc: + _logger.warning("Unreadable %s (%s); using hibernate", PC_WAKE_CONFIG, exc) + return HIBERNATE + mode = raw.get("mode") if isinstance(raw, dict) else None + if mode not in MODES: + _logger.warning("Unknown mode %r in %s; using hibernate", mode, PC_WAKE_CONFIG) + return HIBERNATE + return str(mode) + + +def main() -> int: + """Print the mode.""" + sys.stdout.write(f"{read_mode()}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/wake_alarm/_ring_player.py b/wake_alarm/_ring_player.py new file mode 100644 index 0000000..c71d177 --- /dev/null +++ b/wake_alarm/_ring_player.py @@ -0,0 +1,196 @@ +"""Make the PC's speakers ring: user PipeWire, or a root ALSA unit. + +Two backends, chosen per ring by :func:`select_backend`: + +* **PipeWire** -- an active graphical session on seat0 belongs to this user, + so its PipeWire is reachable: unmute and max the default sink with + ``wpctl`` and play with ``pw-play``. +* **ALSA unit** -- anything else (the night lockdown: lightdm masked, nobody + logged in, and the user is not in ``audio``): start the root system unit + ``wake-alarm-ring-alsa.service`` through its NOPASSWD sudoers entry. The + unit plays straight to the speakers' card and has its own runtime cap. + +Every process this module starts goes through ``_run`` / ``_popen``, the +seams the test suite replaces so a test can never make this host ring. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from pathlib import Path + +_logger = logging.getLogger(__name__) + +ALSA_UNIT = "wake-alarm-ring-alsa.service" +_GRAPHICAL_TYPES = frozenset({"x11", "wayland"}) +_COMMAND_TIMEOUT = 15.0 + + +def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]: + """Run ``cmd`` to completion; the seam tests replace.""" + return subprocess.run( + cmd, check=False, capture_output=True, text=True, timeout=_COMMAND_TIMEOUT + ) + + +def _popen(cmd: list[str]) -> subprocess.Popen[bytes]: + """Start ``cmd`` in the background; the seam tests replace.""" + return subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def _ok(cmd: list[str]) -> tuple[bool, str]: + """Run ``cmd``; ``(succeeded, output or error)``.""" + try: + result = _run(cmd) + except (OSError, subprocess.SubprocessError) as exc: + return False, str(exc) + output = (result.stdout or "").strip() or (result.stderr or "").strip() + return result.returncode == 0, output + + +def _properties(text: str) -> dict[str, str]: + """``Key=Value`` lines as a dict.""" + pairs = (line.partition("=") for line in text.splitlines()) + return {key: value for key, sep, value in pairs if sep} + + +def graphical_session() -> tuple[bool, str]: + """Whether seat0's active session is this user's graphical one, and why.""" + ok, out = _ok(["loginctl", "show-seat", "seat0", "-p", "ActiveSession"]) + session = _properties(out).get("ActiveSession", "") if ok else "" + if not session: + return False, f"no active session on seat0 ({out or 'empty'})" + ok, out = _ok( + [ + "loginctl", + "show-session", + session, + "-p", + "Type", + "-p", + "Active", + "-p", + "User", + ] + ) + props = _properties(out) if ok else {} + owner = props.get("User", "") + if props.get("Type") not in _GRAPHICAL_TYPES or props.get("Active") != "yes": + return False, f"seat0 session {session} is not an active graphical one" + if owner != str(os.getuid()): + return False, f"seat0 session {session} belongs to uid {owner or '?'}" + return True, f"active {props['Type']} session {session} on seat0" + + +class Backend(Protocol): + """One way of making noise.""" + + name: str + + def start(self) -> tuple[bool, str]: + """Begin ringing; ``(started, detail)``.""" + + def pump(self) -> bool: + """Keep the sound going; False when the backend has failed.""" + + def stop(self) -> None: + """Silence it. Must be safe to call twice.""" + + +class PipeWireBackend: + """``wpctl`` to unmute/max the default sink, ``pw-play`` rise then loop.""" + + name = "pipewire" + + def __init__(self, rise: Path, loop: Path) -> None: + """Play ``rise`` once, then ``loop`` forever.""" + self._rise, self._loop = rise, loop + self._proc: subprocess.Popen[bytes] | None = None + + def _play(self, path: Path) -> None: + """Start ``pw-play`` on ``path`` at full stream volume.""" + self._proc = _popen(["pw-play", "--volume", "1.0", str(path)]) + + def start(self) -> tuple[bool, str]: + """Unmute and max the sink, then play the rise.""" + for cmd in ( + ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "0"], + ["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", "1.0"], + ): + ok, out = _ok(cmd) + if not ok: + return False, f"{' '.join(cmd)} failed: {out}" + try: + self._play(self._rise) + except OSError as exc: + return False, f"pw-play failed: {exc}" + return True, "pw-play started" + + def pump(self) -> bool: + """Restart the loop when a play ends; False when ``pw-play`` errored.""" + if self._proc is None: + return False + code = self._proc.poll() + if code is None: + return True + if code != 0: + _logger.warning("pw-play exited with %d", code) + return False + try: + self._play(self._loop) + except OSError: + return False + return True + + def stop(self) -> None: + """Terminate the current ``pw-play``.""" + proc, self._proc = self._proc, None + if proc is None or proc.poll() is not None: + return + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + +class AlsaUnitBackend: + """The root unit; ``sudo -n systemctl start|stop``.""" + + name = "alsa" + + def __init__(self) -> None: + """Nothing is running until :meth:`start`.""" + self._started = False + + def start(self) -> tuple[bool, str]: + """Start the unit through its NOPASSWD sudoers entry.""" + ok, out = _ok(["sudo", "-n", "systemctl", "start", ALSA_UNIT]) + self._started = ok + return ok, "unit started" if ok else f"sudo systemctl start failed: {out}" + + def pump(self) -> bool: + """The unit loops on its own.""" + return self._started + + def stop(self) -> None: + """Stop the unit; logged, never raised, on failure.""" + if not self._started: + return + self._started = False + ok, out = _ok(["sudo", "-n", "systemctl", "stop", ALSA_UNIT]) + if not ok: + _logger.warning("Could not stop %s: %s", ALSA_UNIT, out) + + +def select_backend(rise: Path, loop: Path) -> tuple[Backend, str]: + """PipeWire when seat0 has this user's graphical session, else ALSA.""" + graphical, why = graphical_session() + if graphical: + return PipeWireBackend(rise, loop), why + return AlsaUnitBackend(), why diff --git a/wake_alarm/_ring_wav.py b/wake_alarm/_ring_wav.py new file mode 100644 index 0000000..715a982 --- /dev/null +++ b/wake_alarm/_ring_wav.py @@ -0,0 +1,120 @@ +"""The PC fallback alarm's sound, generated with stdlib ``wave``. + +Two files, because the players can only loop a file from its start: + +* ``ring.wav`` -- 60 s of the alarm pattern rising from about -26 dB to full + scale (linear in dB, so it is heard as a steady climb); +* ``ring_loop.wav`` -- the same pattern at full scale, looped after the rise. + +The pattern is a classic alarm: four short beeps alternating 880/1320 Hz with +a third harmonic for bite, then a pause. Generated, never committed (the +binary hook forbids it): ``install.sh`` writes the system copy, and the user +backend regenerates its own if that copy is missing. + + python -m wake_alarm._ring_wav --write /some/dir +""" + +from __future__ import annotations + +import argparse +from array import array +import math +from pathlib import Path +import struct +import sys +import wave + +SAMPLE_RATE = 16_000 +RISE_SECONDS = 60.0 +LOOP_SECONDS = 20.0 +RISE_FILENAME = "ring.wav" +LOOP_FILENAME = "ring_loop.wav" + +# install.sh writes the pair here (world-readable, for the root ALSA unit and +# the user alike); the user backend falls back to generating its own copy. +SYSTEM_WAV_DIR = Path("/usr/local/share/wake-alarm") +USER_WAV_DIR = Path.home() / ".local" / "share" / "wake_alarm" + +_FLOOR = 0.05 # -26 dB: audible at the start, never silent +_PEAK = 32_000 # just under int16 full scale, so the harmonic never clips +_BEEP = 0.12 +_GAP = 0.08 +_BEEPS = 4 +_PERIOD = 1.2 +_PITCHES = (880.0, 1320.0) + + +def _tone(t: float) -> float: + """The alarm pattern at time ``t``, in ``[-1, 1]``.""" + phase = t % _PERIOD + slot = int(phase // (_BEEP + _GAP)) + if slot >= _BEEPS or phase - slot * (_BEEP + _GAP) >= _BEEP: + return 0.0 + pitch = _PITCHES[slot % len(_PITCHES)] + angle = 2 * math.pi * pitch * t + # 0.75 sin x + 0.25 sin 3x peaks at 1/sqrt(2); scale it to full scale. + return math.sqrt(2) * (0.75 * math.sin(angle) + 0.25 * math.sin(3 * angle)) + + +def _gain(t: float, rise_seconds: float) -> float: + """Loudness at ``t``: the dB-linear climb, then full scale.""" + if rise_seconds <= 0 or t >= rise_seconds: + return 1.0 + return float(_FLOOR * (1 / _FLOOR) ** (t / rise_seconds)) + + +def samples(seconds: float, *, rise_seconds: float = 0.0) -> array[int]: + """``seconds`` of 16-bit mono samples, rising over ``rise_seconds``.""" + out = array("h") + for index in range(int(seconds * SAMPLE_RATE)): + t = index / SAMPLE_RATE + out.append(round(_PEAK * _gain(t, rise_seconds) * _tone(t))) + return out + + +def write_wav(path: Path, data: array[int]) -> None: + """Write ``data`` as a 16 kHz mono 16-bit WAV at ``path``.""" + path.parent.mkdir(parents=True, exist_ok=True) + # WAV is little-endian whatever the host is; pack explicitly. + frames = struct.pack(f"<{len(data)}h", *data) + with wave.Wave_write(str(path)) as handle: + handle.setnchannels(1) + handle.setsampwidth(2) + handle.setframerate(SAMPLE_RATE) + handle.writeframes(frames) + + +def write_pair( + directory: Path, + *, + rise_seconds: float = RISE_SECONDS, + loop_seconds: float = LOOP_SECONDS, +) -> tuple[Path, Path]: + """Write ``ring.wav`` and ``ring_loop.wav`` into ``directory``.""" + rise, loop = directory / RISE_FILENAME, directory / LOOP_FILENAME + write_wav(rise, samples(rise_seconds, rise_seconds=rise_seconds)) + write_wav(loop, samples(loop_seconds)) + return rise, loop + + +def ensure_pair(preferred: Path, fallback: Path) -> tuple[Path, Path]: + """The installed pair if both exist, else a pair generated in ``fallback``.""" + for directory in (preferred, fallback): + rise, loop = directory / RISE_FILENAME, directory / LOOP_FILENAME + if rise.is_file() and loop.is_file(): + return rise, loop + return write_pair(fallback) + + +def main(argv: list[str] | None = None) -> int: + """Write the pair into ``--write DIR``.""" + parser = argparse.ArgumentParser(description="Generate the fallback ring WAVs") + parser.add_argument("--write", type=Path, required=True, metavar="DIR") + args = parser.parse_args(sys.argv[1:] if argv is None else argv) + for path in write_pair(args.write): + sys.stdout.write(f"{path}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/wake_alarm/_rtc.py b/wake_alarm/_rtc.py new file mode 100644 index 0000000..3ea98ff --- /dev/null +++ b/wake_alarm/_rtc.py @@ -0,0 +1,167 @@ +"""Arm the RTC so the PC is up before a stick-day alarm. + +``wake-alarm-rtc.timer`` runs ``--arm`` every 15 min, all day, and once +after boot; a calendar timer that elapsed while the PC slept catches up on +resume, so a PC hibernated at 16:00 on Monday is armed for Tuesday the +moment it next runs at all. The target is the first morning whose wake instant +(``A - RTC_WAKE_LEAD``, ``A`` clamped to 08:00) is still ahead -- so a +02:00 tick on a Tuesday targets Tuesday, not Wednesday. Only a stick day +(``freedays.is_workday``: Tue/Wed/Thu) that is not a free day is armed, via +``sudo -n /usr/sbin/rtcwake -m no -t ``. The alarm lives in CMOS, so +it survives however the PC goes down. + +When the next morning is not one to wake for (a carrot day, or a stick day +that became free), an alarm *we* armed is cleared (``rtc_disarmed``); one +someone else set is left alone (``_rtc_hw``). A changed alarm time needs no +disarm: arming overwrites the single RTC alarm. + +``rtc_armed`` / ``rtc_skip`` are logged only when they change; the audit +matches ``rtc_armed`` on its ``target_date``. + + python -m wake_alarm._rtc --arm # what the timer runs + python -m wake_alarm._rtc --dry-run # the plan, nothing armed + python -m wake_alarm._rtc --show # the plan + the RTC's current alarm +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, date, datetime, timedelta +import sys + +import freedays + +from wake_alarm._cli_modes import mode_parser, parse_with_now +from wake_alarm._constants import RTC_WAKE_LEAD +from wake_alarm._events import emit, emit_on_change +from wake_alarm._fallback_policy import alarm_instant +from wake_alarm._logging_setup import configure_logging +from wake_alarm._pc_wake_mode import read_mode +from wake_alarm._rtc_hw import arm, current_alarm, disarm_ours, read_record +from wake_alarm._sync import Alarm, get_alarm + + +@dataclass(frozen=True) +class Plan: + """Arm for ``target`` at ``wake_at``, or skip it with a reason.""" + + target: date + wake_at: datetime + arm: bool + reason: str + + @property + def epoch(self) -> int: + """``wake_at`` as whole epoch seconds.""" + return int(self.wake_at.timestamp()) + + +def _now() -> datetime: + """Aware local now; the seam tests patch.""" + return datetime.now(tz=UTC).astimezone() + + +def plan(now: datetime, alarm: Alarm) -> Plan: + """Which morning is next, and whether it is one to wake the PC for.""" + target = now.date() + wake_at = alarm_instant(alarm, target) - RTC_WAKE_LEAD + if wake_at <= now: + target += timedelta(days=1) + wake_at = alarm_instant(alarm, target) - RTC_WAKE_LEAD + if not freedays.is_workday(target): + return Plan( + target, wake_at, arm=False, reason=f"{target:%a} is not a stick day" + ) + if freedays.is_free_day(target): + return Plan(target, wake_at, arm=False, reason="free day") + return Plan(target, wake_at, arm=True, reason="stick day") + + +def release(chosen: Plan) -> None: + """No morning to wake for: clear the alarm we armed, if still pending.""" + record, disarmed, detail = disarm_ours() + if record is None or detail == "no longer pending": + return + if disarmed: + emit( + "rtc_disarmed", + f"cleared {record.target_date} ({chosen.reason})", + epoch=record.epoch, + target_date=record.target_date, + reason=chosen.reason, + ) + return + emit_on_change( + "rtc_skip", + f"{chosen.target}|disarm_failed", + f"rtcwake -m disable failed: {detail}", + epoch=record.epoch, + target_date=record.target_date, + ) + + +def apply(chosen: Plan) -> bool: + """Arm or skip, logging ``rtc_armed`` / ``rtc_skip`` on change.""" + fields = { + "target_date": chosen.target.isoformat(), + "wake_at": chosen.wake_at.isoformat(), + "epoch": chosen.epoch, + "mode": read_mode(), + } + if not chosen.arm: + emit_on_change( + "rtc_skip", f"{chosen.target}|{chosen.reason}", chosen.reason, **fields + ) + release(chosen) + return False + ok, detail = arm(chosen.epoch, chosen.target.isoformat()) + if not ok: + emit_on_change( + "rtc_skip", f"{chosen.target}|failed", f"rtcwake failed: {detail}", **fields + ) + return False + emit_on_change( + "rtc_armed", str(chosen.epoch), f"wake at {chosen.wake_at:%a %H:%M}", **fields + ) + return True + + +def describe(chosen: Plan) -> str: + """One human line for ``--dry-run`` / ``--show``.""" + verdict = "ARM" if chosen.arm else "skip" + return ( + f"{verdict} {chosen.target} ({chosen.reason}): wake at " + f"{chosen.wake_at:%Y-%m-%d %H:%M %Z} epoch {chosen.epoch}\n" + ) + + +def main(argv: list[str] | None = None) -> int: + """Entry point.""" + parser = mode_parser("Arm the RTC for a stick day", "arm", "arm (the timer)") + args, now = parse_with_now(parser, argv, _now) + chosen = plan(now, get_alarm()) + if args.arm: + configure_logging(component="rtc") + return 0 if apply(chosen) or not chosen.arm else 1 + sys.stdout.write(describe(chosen)) + record = read_record() + if not chosen.arm and record is not None and current_alarm() == record.epoch: + sys.stdout.write( + f"would disarm ours: {record.epoch} for {record.target_date}\n" + ) + if args.show: + pending = current_alarm() + when = ( + datetime.fromtimestamp(pending, tz=UTC).astimezone().isoformat() + if pending + else "none" + ) + ours = "none" if record is None else f"{record.epoch} for {record.target_date}" + sys.stdout.write( + f"rtc wakealarm: {when}\narmed by us: {ours}\nmode: {read_mode()}\n" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/wake_alarm/_rtc_hw.py b/wake_alarm/_rtc_hw.py new file mode 100644 index 0000000..21eca5a --- /dev/null +++ b/wake_alarm/_rtc_hw.py @@ -0,0 +1,122 @@ +"""The RTC wake alarm itself, and our record of the one we set. + +Split from ``_rtc`` (which morning, and whether): this is the hardware side. +The pending alarm is read without root from ``/sys/class/rtc/rtc0/wakealarm``; +setting and clearing go through the NOPASSWD ``sudo -n /usr/sbin/rtcwake``. + +``RTC_ARMED_FILE`` (``{"epoch", "target_date"}``) remembers the alarm *we* +armed, so :func:`disarm_ours` only ever clears that one: an alarm someone +else set (a manual ``rtcwake``) is not ours to remove. The comparison is on +the sysfs value, which is epoch seconds when the RTC keeps UTC (this host: +no ``/etc/adjtime``). An RTC on local time would never match, which fails +safe -- the alarm is left in place, never cleared by mistake. + +Every process goes through ``_run``, the seam the suite replaces. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import logging +from pathlib import Path +import subprocess + +from wake_alarm._constants import RTC_ARMED_FILE + +_logger = logging.getLogger(__name__) + +RTCWAKE = "/usr/sbin/rtcwake" +WAKEALARM_FILE = Path("/sys/class/rtc/rtc0/wakealarm") + + +@dataclass(frozen=True) +class ArmedRecord: + """The alarm this package last armed.""" + + epoch: int + target_date: str + + +def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]: + """Run ``cmd``; the seam tests replace so nothing ever arms this host.""" + return subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=15) + + +def _rtcwake(*args: str) -> tuple[bool, str]: + """``sudo -n rtcwake ``; ``(succeeded, detail)``.""" + try: + result = _run(["sudo", "-n", RTCWAKE, *args]) + except (OSError, subprocess.SubprocessError) as exc: + return False, str(exc) + detail = (result.stderr or result.stdout or "").strip() + return result.returncode == 0, detail + + +def current_alarm() -> int | None: + """The RTC's pending wake alarm (epoch), or None when none is pending.""" + try: + text = WAKEALARM_FILE.read_text(encoding="ascii").strip() + except OSError: + return None + return int(text) if text.isdigit() else None + + +def read_record() -> ArmedRecord | None: + """What we last armed, or None.""" + try: + raw = json.loads(RTC_ARMED_FILE.read_text(encoding="utf-8")) + except OSError, ValueError: + return None + epoch = raw.get("epoch") if isinstance(raw, dict) else None + target = raw.get("target_date") if isinstance(raw, dict) else None + if isinstance(epoch, bool) or not isinstance(epoch, int): + return None + return ArmedRecord(epoch, target if isinstance(target, str) else "") + + +def _write_record(record: ArmedRecord | None) -> None: + """Persist (or, for None, delete) the record; failures only log.""" + try: + if record is None: + RTC_ARMED_FILE.unlink(missing_ok=True) + return + RTC_ARMED_FILE.parent.mkdir(parents=True, exist_ok=True) + RTC_ARMED_FILE.write_text( + json.dumps({"epoch": record.epoch, "target_date": record.target_date}) + ) + except OSError as exc: + _logger.warning("Could not update %s: %s", RTC_ARMED_FILE, exc) + + +def arm(epoch: int, target_date: str) -> tuple[bool, str]: + """Set the RTC alarm (unless it already is exactly this) and record it.""" + if current_alarm() == epoch: + ok, detail = True, "already set" + else: + ok, detail = _rtcwake("-m", "no", "-t", str(epoch)) + detail = detail or "armed" + if ok: + _write_record(ArmedRecord(epoch, target_date)) + return ok, detail + + +def disarm_ours() -> tuple[ArmedRecord | None, bool, str]: + """Clear the pending alarm only if it is the one we recorded. + + Returns: + ``(record, disarmed, detail)``. ``record`` is None when we had armed + nothing; ``disarmed`` is True only when ``rtcwake -m disable`` ran + and succeeded. A record whose alarm is no longer pending (it fired, + or someone else replaced it) is simply forgotten. + """ + record = read_record() + if record is None: + return None, False, "nothing of ours armed" + if current_alarm() != record.epoch: + _write_record(None) + return record, False, "no longer pending" + ok, detail = _rtcwake("-m", "disable") + if ok: + _write_record(None) + return record, ok, detail or "disarmed" diff --git a/wake_alarm/_session.py b/wake_alarm/_session.py index 6bc6cfc..e49de4e 100644 --- a/wake_alarm/_session.py +++ b/wake_alarm/_session.py @@ -35,6 +35,8 @@ import sys from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac from wake_alarm._constants import FALLBACK_ALARM_HOUR, MORNING_SESSION_FILE +from wake_alarm._enforcement_payload import enforcement_view +from wake_alarm._escapes import note_escapes from wake_alarm._logging_setup import configure_logging from wake_alarm._session_payload import newest_session from wake_alarm._session_policy import to_entry @@ -104,7 +106,11 @@ def refresh(now: datetime) -> dict[str, object] | None: ) if not write_entry(entry): return None - _maybe_apply_workday_penalty(entry, now) + # Escapes first: cancelling an escaped day's penalty must not depend on + # today being a stick day (the escape may arrive the morning after). + view = enforcement_view(published) + note_escapes(view) + _maybe_apply_workday_penalty(entry, now, escaped_dates=view.escaped_dates()) return entry diff --git a/wake_alarm/_workday_penalty.py b/wake_alarm/_workday_penalty.py index 51f5649..875f993 100644 --- a/wake_alarm/_workday_penalty.py +++ b/wake_alarm/_workday_penalty.py @@ -13,6 +13,9 @@ and is overwritten at the date rollover, so it cannot carry a marker forward to tomorrow. This file's only field a consumer checks is ``penalty_date``: today == penalty_date means "no leniency today," the same shape as ``morning_session.json``'s own ``exempt_until`` predicate. + +An escape (``DOCS-wake-enforcement.md``) for a morning means no penalty is +written for it, and one already issued that day is deleted. """ from __future__ import annotations @@ -25,6 +28,7 @@ import freedays from gatelock.log_integrity import compute_entry_hmac from wake_alarm._constants import WORKDAY_PENALTY_FILE +from wake_alarm._events import emit from wake_alarm._session_policy import NONE_OUTCOME _logger = logging.getLogger(__name__) @@ -81,7 +85,40 @@ def write_penalty(entry: dict[str, object]) -> bool: return True -def maybe_apply(entry: dict[str, object], now: datetime) -> bool: +def issued_date() -> str | None: + """``issued_date`` of the penalty on disk, or None when there is none.""" + try: + raw = json.loads(WORKDAY_PENALTY_FILE.read_text()) + except OSError, ValueError: + return None + issued = raw.get("issued_date") if isinstance(raw, dict) else None + return issued if isinstance(issued, str) else None + + +def cancel_for_escapes(escaped_dates: frozenset[str]) -> bool: + """Delete the penalty when an escape excuses the morning it was issued. + + Returns: + True when a penalty was removed. + """ + issued = issued_date() + if issued is None or issued not in escaped_dates: + return False + try: + WORKDAY_PENALTY_FILE.unlink() + except OSError as exc: + _logger.warning("Could not cancel %s: %s", WORKDAY_PENALTY_FILE, exc) + return False + emit("penalty_cancelled", f"escape for {issued}", issued_date=issued) + return True + + +def maybe_apply( + entry: dict[str, object], + now: datetime, + *, + escaped_dates: frozenset[str] = frozenset(), +) -> bool: """Write tomorrow's penalty when today is a missed stick-day; else no-op. Best-effort: never raises, since the per-refresh caller must not fail on @@ -89,10 +126,28 @@ def maybe_apply(entry: dict[str, object], now: datetime) -> bool: would otherwise have rung the alarm -- the pool is fail-closed the other direction (a corrupt read is "not free"), which is exactly what is wanted here too: an unreadable pool means no penalty, never a spurious - one. + one. An escaped morning is never penalised either. + + ``penalty_written`` is logged once per issued day, not on every + two-minute refresh that rewrites the same entry. """ - if freedays.is_free_day(now.date()) or not is_stick_day(now.date()): + today = now.date() + if today.isoformat() in escaped_dates: + return False + if freedays.is_free_day(today) or not is_stick_day(today): return False if not is_workday_miss(entry, now): return False - return write_penalty(penalty_entry(today=now.date())) + previously = issued_date() + entry_body = penalty_entry(today=today) + if not write_penalty(entry_body): + return False + if previously != today.isoformat(): + emit( + "penalty_written", + f"penalty for {entry_body['penalty_date']}", + penalty_date=entry_body["penalty_date"], + issued_date=entry_body["issued_date"], + outcome=entry.get("outcome"), + ) + return True diff --git a/wake_alarm/tests/conftest.py b/wake_alarm/tests/conftest.py index 7be7bf7..871d6fb 100644 --- a/wake_alarm/tests/conftest.py +++ b/wake_alarm/tests/conftest.py @@ -19,10 +19,16 @@ an inhibitor: ``~/.local/state/wake_alarm/events.jsonl``. Unredirected, every suite run appended fixture warnings ("Synced alarm out of range: 24:0") to the log a failed wake-up is reconstructed from. +* Wake enforcement v2 writes more state (the fallback latch, the event + state, incident reports) and reaches real hardware (``pw-play``, + ``sudo systemctl``, ``rtcwake``, ``journalctl``). Every path is redirected + and every process seam raises unless a test replaces it, so no test can + ring this machine's speakers or arm its RTC. """ from __future__ import annotations +from contextlib import ExitStack from typing import TYPE_CHECKING from unittest.mock import patch @@ -34,12 +40,20 @@ if TYPE_CHECKING: from pathlib import Path +def _no_process(cmd: list[str]) -> object: + """Stand-in for every process seam: an unmocked test fails loudly.""" + msg = f"test tried to run {cmd!r}" + raise AssertionError(msg) + + @pytest.fixture(autouse=True) def _hermetic(tmp_path: Path) -> Generator[None]: - """Keep signing keys and state writes inside ``tmp_path``.""" + """Keep signing keys, state writes and processes inside ``tmp_path``.""" key_file = tmp_path / "hmac.key" key_file.write_bytes(b"0" * 32) - with ( + # An ExitStack, not one `with (...)`: Python caps a statement at 20 + # nested blocks, and every patch is one. + patches = [ patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key_file), patch("wake_alarm._state.WAKE_STATE_FILE", tmp_path / "wake_state.json"), # _logging_setup imported these by value, so patch its names, not @@ -54,6 +68,31 @@ def _hermetic(tmp_path: Path) -> Generator[None]: "wake_alarm._workday_penalty.WORKDAY_PENALTY_FILE", tmp_path / "workday_penalty.json", ), + # Read by every get_alarm() the v2 modules make; the live file is + # the trigger's cache. A missing Firebase config keeps fetches + # off the network unless a test patches the fetch itself. + patch("wake_alarm._sync.ALARM_CACHE_FILE", tmp_path / "alarm_cache.json"), + patch( + "wake_alarm._sync_transport.FIREBASE_CONFIG_FILE", + tmp_path / "no-firebase.json", + create=True, + ), + patch("wake_alarm._events.EVENT_STATE_FILE", tmp_path / "event_state.json"), + patch( + "wake_alarm._fallback_latch.FALLBACK_RING_FILE", + tmp_path / "fallback_ring.json", + ), + patch("wake_alarm._pc_wake_mode.PC_WAKE_CONFIG", tmp_path / "pc_wake.json"), + patch("wake_alarm._audit.INCIDENTS_DIR", tmp_path / "incidents"), + patch("wake_alarm._audit_sources.LOG_JSONL_FILE", tmp_path / "audit.jsonl"), + patch("wake_alarm._fallback.SYSTEM_WAV_DIR", tmp_path / "wav-system"), + patch("wake_alarm._fallback.USER_WAV_DIR", tmp_path / "wav-user"), + patch("wake_alarm._rtc_hw.WAKEALARM_FILE", tmp_path / "wakealarm"), + patch("wake_alarm._rtc_hw.RTC_ARMED_FILE", tmp_path / "rtc_armed.json"), + patch("wake_alarm._ring_player._run", _no_process), + patch("wake_alarm._ring_player._popen", _no_process), + patch("wake_alarm._rtc_hw._run", _no_process), + patch("wake_alarm._audit_sources._run", _no_process), # The shared free-day pool lives under ~/.local/share, outside every # path above. Without this, a real free day on the developer's # machine would silence the alarm in every trigger test here. @@ -61,5 +100,8 @@ def _hermetic(tmp_path: Path) -> Generator[None]: "freedays._api.resolve_paths", lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"), ), - ): + ] + with ExitStack() as stack: + for redirect in patches: + stack.enter_context(redirect) yield diff --git a/wake_alarm/tests/test_audit.py b/wake_alarm/tests/test_audit.py new file mode 100644 index 0000000..72813e4 --- /dev/null +++ b/wake_alarm/tests/test_audit.py @@ -0,0 +1,190 @@ +"""The audit report end to end, with every source faked.""" + +from __future__ import annotations + +from datetime import date +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from wake_alarm import _audit, _audit_sources +from wake_alarm._audit import gather, main, render +from wake_alarm._audit_checks import run_checks +from wake_alarm._audit_sources import Up +from wake_alarm._enforcement_records import PhoneEvent +from wake_alarm._sync import Alarm + +if TYPE_CHECKING: + from collections.abc import Generator +from dataclasses import replace + +DAY = date(2026, 10, 1) +REASON = "the alarm never rang because the phone rebooted overnight" + + +def _published() -> list[tuple[str, str]]: + fields = { + "phone_events": [ + [ + {"at": "2026-10-01T07:00:02+02:00", "kind": "alarm_fired", "data": {}}, + {"at": "2026-09-20T07:00:02+02:00", "kind": "boot", "data": {}}, + {"at": "soon", "kind": "boot", "data": {}}, + ], + "h", + ], + "escapes": [ + [{"id": "e", "date": "2026-10-01", "at": "x", "reason": REASON}], + "h", + ], + } + return [("p", json.dumps({"alarm": {"fields": fields}}))] + + +@pytest.fixture(autouse=True) +def _sources() -> Generator[None]: + with ( + patch.object(_audit, "fetch_firebase_logs", return_value=_published()), + patch.object(_audit, "get_alarm", return_value=Alarm(7, 0, "cache")), + patch.object(_audit, "boots", return_value=[]), + patch.object(_audit, "resumes", return_value=[]), + ): + yield + + +def test_gather_merges_phone_and_pc() -> None: + log = _audit_sources.LOG_JSONL_FILE + log.write_text( + json.dumps( + { + "ts": "2026-10-01T06:55:00+0200", + "event": "fallback_tick", + "fields": {"alarm": "2026-10-01T07:00:00+02:00"}, + } + ) + ) + evidence, reachable = gather(DAY) + assert reachable + assert evidence.alarm_source == "PC fallback tick" + assert [e.kind for e in evidence.phone] == ["alarm_fired"] + assert [e.id for e in evidence.escapes] == ["e"] + + +def test_unreachable_firebase_is_flagged() -> None: + with patch.object(_audit, "fetch_firebase_logs", return_value=None): + evidence, reachable = gather(DAY) + assert not reachable + assert evidence.alarm_source == "current alarm, assumed" + text = render(evidence, run_checks(evidence), reachable=False) + assert "firebase: unreachable" in text + + +def test_render_puts_flags_first_and_builds_a_timeline() -> None: + with patch.object(_audit, "resumes", return_value=[]): + evidence, _ = gather(DAY) + evidence = replace( + evidence, + pc_side=replace(evidence.pc_side, ups=[Up(evidence.alarm_at, "resume S4")]), + ) + text = render(evidence, run_checks(evidence), reachable=True) + assert text.index("## Flags") < text.index("ESCAPE") < text.index("## Checks") + assert "| phone | alarm_fired |" in text + assert "| kernel | resume S4 |" in text + assert "(Thu, stick day)" in text + + +def test_an_empty_day_says_so() -> None: + with patch.object(_audit, "fetch_firebase_logs", return_value=[]): + evidence, _ = gather(date(2026, 10, 3)) + text = render(evidence, [], reachable=True) + assert "- none" in text + assert "(nothing recorded)" in text + assert "carrot day" in text + with patch.object(_audit.freedays, "is_free_day", return_value=True): + evidence, _ = gather(date(2026, 10, 3)) + assert "free day" in render(evidence, [], reachable=True) + + +def test_pc_event_details_are_rendered() -> None: + log = _audit_sources.LOG_JSONL_FILE + log.write_text( + json.dumps( + { + "ts": "2026-10-01T07:02:00+0200", + "event": "ring_backend", + "fields": {"a": 1}, + } + ) + ) + evidence, _ = gather(DAY) + assert "| PC | ring_backend | a=1 |" in render(evidence, [], reachable=True) + + +class TestCli: + def test_dry_run_writes_nothing(self, capsys: pytest.CaptureFixture[str]) -> None: + assert main(["2026-10-01", "--dry-run"]) == 0 + assert "# Wake audit 2026-10-01" in capsys.readouterr().out + assert not _audit.INCIDENTS_DIR.exists() + + def test_writes_the_report(self) -> None: + with ( + patch.object(_audit, "configure_logging"), + patch("sys.argv", ["prog", "2026-10-01"]), + ): + assert main() == 0 + report = _audit.INCIDENTS_DIR / "2026-10-01.md" + assert report.read_text().startswith("# Wake audit 2026-10-01") + + def test_defaults_to_today(self) -> None: + with patch.object(_audit, "configure_logging"): + assert main([]) == 0 + assert len(list(_audit.INCIDENTS_DIR.iterdir())) == 1 + + def test_unwritable_report_exits_1( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with ( + patch.object(_audit, "configure_logging"), + patch.object(_audit, "write_report", side_effect=OSError("ro")), + ): + assert main(["2026-10-01"]) == 1 + assert "could not write" in capsys.readouterr().err + + +def test_timeline_skips_an_unparsable_phone_stamp() -> None: + evidence, _ = gather(DAY) + odd = replace( + evidence, + phone_side=replace( + evidence.phone_side, events=[PhoneEvent("soon", "boot", {})] + ), + ) + assert "| phone |" not in render(odd, [], reachable=True) + + +def test_a_late_check_for_the_day_is_on_its_timeline() -> None: + fields = { + "phone_events": [ + [ + { + "at": "2026-10-03T09:00:00+02:00", + "kind": "stick_check", + "data": { + "date": "2026-10-01", + "verdict": "miss", + "trigger": "boot", + }, + }, + {"at": "2026-10-03T09:00:01+02:00", "kind": "boot", "data": {}}, + ], + "h", + ] + } + published = [("p", json.dumps({"alarm": {"fields": fields}}))] + with patch.object(_audit, "fetch_firebase_logs", return_value=published): + evidence, _ = gather(DAY) + text = render(evidence, run_checks(evidence), reachable=True) + assert "| phone | stick_check |" in text + assert "| phone | boot |" not in text + assert "miss caught up on 2026-10-03" in text diff --git a/wake_alarm/tests/test_audit_checks.py b/wake_alarm/tests/test_audit_checks.py new file mode 100644 index 0000000..a7c2fa6 --- /dev/null +++ b/wake_alarm/tests/test_audit_checks.py @@ -0,0 +1,232 @@ +"""Expected-vs-observed checks, one scenario at a time.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import date, datetime, timedelta + +import pytest + +from wake_alarm._audit_checks import ( + alarm_from_events, + check_alarm, + check_fallback_stop, + check_lockdown, + check_penalty, + check_ring, + check_rtc, + check_up_by_alarm, + flags, + run_checks, +) +from wake_alarm._audit_evidence import Evidence, PcSide, PhoneSide +from wake_alarm._audit_sources import PcEvent, Up +from wake_alarm._enforcement_records import Escape, PhoneEvent, StickState + +DAY = date(2026, 10, 1) # a Thursday: stick day +A = datetime(2026, 10, 1, 7, 0).astimezone() +REASON = "the alarm never rang because the phone rebooted overnight" + + +def _pc(minutes: float, event: str, **fields: object) -> PcEvent: + return PcEvent(A + timedelta(minutes=minutes), event, "", dict(fields)) + + +def _phone(minutes: float, kind: str, **data: object) -> PhoneEvent: + return PhoneEvent((A + timedelta(minutes=minutes)).isoformat(), kind, dict(data)) + + +_PC_KEYS = { + "pc": "events", + "pc_seen": "seen", + "ups": "ups", + "penalty_issued": "penalty_issued", +} +_PHONE_KEYS = { + "phone": "events", + "escapes": "escapes", + "ring_started": "ring_started", + "stick_state": "stick_state", +} + + +def _ev(**overrides: object) -> Evidence: + """Evidence for a stick-day morning, with flat names for either side.""" + pc = {_PC_KEYS[k]: overrides.pop(k) for k in list(overrides) if k in _PC_KEYS} + phone = { + _PHONE_KEYS[k]: overrides.pop(k) for k in list(overrides) if k in _PHONE_KEYS + } + base = Evidence( + day=DAY, + stick_day=True, + free_day=False, + alarm_at=A, + alarm_source="t", + pc_side=PcSide(**pc), + phone_side=PhoneSide(**phone), + ) + return replace(base, **overrides) + + +ESCAPE = Escape("e", DAY.isoformat(), "2026-10-01T07:30:00", REASON) + + +def test_alarm_from_events() -> None: + events = [ + _pc(-5, "fallback_tick", alarm="2026-09-30T07:00:00+02:00"), + _pc(-5, "fallback_tick", alarm=7), + _pc(-5, "fallback_tick", alarm="nonsense"), + _pc(-5, "fallback_tick", alarm=A.isoformat()), + ] + assert alarm_from_events(events, DAY) == A + assert alarm_from_events([], DAY) is None + + +def test_alarm_known() -> None: + assert check_alarm(_ev()).ok + assert check_alarm(_ev(alarm_at=None)).ok is False + + +def test_rtc_armed() -> None: + assert check_rtc(_ev(stick_day=False)).ok is None + assert check_rtc(_ev()).ok is False + armed = _pc(-600, "rtc_armed", target_date=DAY.isoformat(), wake_at="06:50") + assert check_rtc(_ev(pc=[armed])).ok + + +def test_up_by_alarm() -> None: + assert check_up_by_alarm(_ev(free_day=True)).ok is None + assert check_up_by_alarm(_ev()).ok is False + resumed = Up(A - timedelta(minutes=9), "resume S4") + assert check_up_by_alarm(_ev(ups=[resumed])).ok + late = Up(A + timedelta(minutes=9), "boot") + assert check_up_by_alarm(_ev(ups=[late])).ok is False + assert check_up_by_alarm(_ev(pc_seen=[A - timedelta(minutes=3)])).ok + + +def test_something_rang() -> None: + assert check_ring(_ev(free_day=True)).ok is None + assert check_ring(_ev()).ok is False + assert check_ring(_ev(ring_started=True)).detail == "phone" + assert check_ring(_ev(phone=[_phone(0, "alarm_fired")])).ok + both = _ev(ring_started=True, pc=[_pc(2, "fallback_ring_start")]) + assert check_ring(both).detail == "phone + PC fallback" + + +def test_fallback_stop() -> None: + assert check_fallback_stop(_ev()).ok is None + start = _pc(2, "fallback_ring_start") + assert check_fallback_stop(_ev(pc=[start])).ok is False + died = _pc(5, "fallback_ring_stop", stop_reason="terminated") + assert check_fallback_stop(_ev(pc=[start, died])).ok is False + good = _pc(9, "fallback_ring_stop", stop_reason="session_started") + assert check_fallback_stop(_ev(pc=[start, good])).ok + + +def test_penalty() -> None: + day = DAY.isoformat() + assert check_penalty(_ev(escapes=[ESCAPE], penalty_issued=day)).ok is False + assert check_penalty(_ev(escapes=[ESCAPE])).ok + assert check_penalty(_ev(stick_day=False)).ok is None + missed = StickState(1, "2026-10-02", day) + assert check_penalty(_ev(stick_state=missed)).ok is False + written = _pc(16, "penalty_written") + assert check_penalty(_ev(stick_state=missed, pc=[written])).detail == ( + "penalty written" + ) + assert check_penalty(_ev()).detail == "no miss, no penalty" + + +class TestLockdown: + missed = StickState(1, "2026-10-02", DAY.isoformat()) + sent = _phone(15, "lockdown_sent", until="2026-10-02", streak=1) + + def test_no_lockdown(self) -> None: + assert check_lockdown(_ev()).ok is None + + def test_consistent(self) -> None: + assert check_lockdown(_ev(stick_state=self.missed, phone=[self.sent])).ok + + @pytest.mark.parametrize( + "evidence", + [ + {"stick_state": missed}, + { + "stick_state": StickState(1, "2026-10-03", DAY.isoformat()), + "phone": [sent], + }, + {"phone": [sent]}, + {"phone": [sent], "escapes": [ESCAPE]}, + ], + ) + def test_mismatches(self, evidence: dict[str, object]) -> None: + assert check_lockdown(_ev(**evidence)).ok is False + + def test_escape_with_lift_is_fine(self) -> None: + lifted = [self.sent, _phone(31, "lift_sent")] + assert check_lockdown(_ev(phone=lifted, escapes=[ESCAPE])).ok + + def test_other_days_and_bad_stamps_are_ignored(self) -> None: + other = PhoneEvent("2026-09-30T07:15:00", "lockdown_sent", {}) + bad = PhoneEvent("soon", "lockdown_sent", {}) + assert check_lockdown(_ev(phone=[other, bad])).ok is None + + +def test_flags_put_escapes_first() -> None: + ignored = Escape("x", DAY.isoformat(), "", "short") + ev = _ev(escapes=[ESCAPE, ignored]) + lines = flags(ev, run_checks(ev)) + assert lines[0].startswith("ESCAPE 2026-10-01T07:30:00") + assert "IGNORED" in lines[1] + assert lines[2].startswith("MISMATCH") + assert ESCAPE.reason in lines[0] + + +def test_only_the_daily_firing_counts_as_the_morning_ring() -> None: + for reason in ("free_day", "rering"): + fired = _phone(0, "alarm_fired", reason=reason) + assert check_ring(_ev(phone=[fired])).ok is False + assert check_ring(_ev(phone=[_phone(0, "alarm_fired", reason="daily")])).ok + assert check_ring(_ev(phone=[_phone(0, "ring_screen_opened")])).ok + + +class TestStickVerdicts: + """Verdicts are keyed by data.date, the morning judged.""" + + def _check( + self, minutes: float, verdict: str, day: str = "2026-10-01" + ) -> PhoneEvent: + return _phone(minutes, "stick_check", trigger="boot", verdict=verdict, date=day) + + def test_a_late_boot_check_judges_the_right_morning(self) -> None: + two_days_later = self._check(2 * 24 * 60, "miss") + ev = _ev(phone=[two_days_later]) + assert check_penalty(ev).ok is False # miss, and the PC wrote none + assert check_lockdown(ev).detail.startswith("miss caught up on 2026-10-03") + + def test_provisional_and_other_days_are_ignored(self) -> None: + ev = _ev( + phone=[self._check(15, "too_early"), self._check(15, "miss", "2026-09-30")] + ) + assert check_penalty(ev).detail == "no miss, no penalty" + assert check_lockdown(ev).ok is None + + def test_a_same_day_miss_expects_its_lockdown(self) -> None: + miss = self._check(15, "miss") + assert check_lockdown(_ev(phone=[miss])).ok is False + sent = _phone(15, "lockdown_sent", until="2026-10-02") + other = _phone(16, "dismissed") + assert ( + check_lockdown(_ev(phone=[miss, other, sent])).detail == "until 2026-10-02" + ) + + def test_an_unparsable_judging_stamp_counts_as_the_day_itself(self) -> None: + odd = PhoneEvent( + "soon", "stick_check", {"date": "2026-10-01", "verdict": "miss"} + ) + bad_stamp = PhoneEvent("soon", "lockdown_sent", {"until": "x"}) + assert check_lockdown(_ev(phone=[odd, bad_stamp])).ok is False + + def test_a_non_miss_verdict_falls_through(self) -> None: + ev = _ev(phone=[self._check(15, "dismissed")]) + assert check_penalty(ev).detail == "no miss, no penalty" diff --git a/wake_alarm/tests/test_audit_sources.py b/wake_alarm/tests/test_audit_sources.py new file mode 100644 index 0000000..fedeb7b --- /dev/null +++ b/wake_alarm/tests/test_audit_sources.py @@ -0,0 +1,140 @@ +"""The audit's evidence readers, with journalctl faked.""" + +from __future__ import annotations + +from datetime import date, datetime +import json +import logging +import subprocess +from unittest.mock import MagicMock, patch + +from wake_alarm import _audit_sources +from wake_alarm._audit_sources import ( + audit_window, + boots, + phone_window, + read_pc_log, + resumes, + timeline_window, +) +from wake_alarm._events import emit +from wake_alarm._log_sink import build_handler + +# Bound at import, before conftest swaps the seam for a raiser. +REAL_RUN = _audit_sources._run +DAY = date(2026, 10, 1) + + +def _done(stdout: str, code: int = 0) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess([], code, stdout=stdout, stderr="") + + +def _line(ts: str, **extra: object) -> str: + return json.dumps({"ts": ts, "msg": "m", **extra}) + + +def _usec(*parts: int) -> int: + return int(datetime(*parts).astimezone().timestamp() * 1_000_000) + + +def test_windows() -> None: + start, end = audit_window(DAY) + assert start == datetime(2026, 9, 30, 0, 0).astimezone() + assert end == datetime(2026, 10, 2, 0, 0).astimezone() + assert phone_window(DAY) == (start, datetime(2026, 10, 7, 0, 0).astimezone()) + assert timeline_window(DAY) == (datetime(2026, 9, 30, 18, 0).astimezone(), end) + + +def test_pc_log_reads_rotated_files_and_keeps_named_events() -> None: + live = _audit_sources.LOG_JSONL_FILE + rotated = live.with_name(live.name + ".1") + rotated.write_text( + _line("2026-09-30T21:00:00+0200", event="rtc_armed", fields={"a": 1}) + "\n" + ) + live.write_text( + "\n".join( + [ + _line("2026-10-01T06:50:00+0200"), + _line("2026-10-01T07:02:00+0200", event="fallback_tick", fields=[1]), + _line("2026-09-29T07:00:00+0200", event="old"), + _line("garbage"), + "not json", + "[1, 2]", + ] + ) + ) + named, seen = read_pc_log(DAY) + assert [e.event for e in named] == ["rtc_armed", "fallback_tick"] + assert named[0].fields == {"a": 1} + assert named[1].fields == {} + assert len(seen) == 3 + + +def test_pc_log_skips_unreadable_files() -> None: + _audit_sources.LOG_JSONL_FILE.mkdir() + assert read_pc_log(DAY) == ([], []) + + +def test_boots_in_the_window() -> None: + listed = [ + {"first_entry": _usec(2026, 10, 1, 6, 50)}, + {"first_entry": _usec(2026, 9, 20, 6, 50)}, + {"first_entry": "x"}, + "junk", + ] + with patch.object(_audit_sources, "_run", return_value=_done(json.dumps(listed))): + found = boots(DAY) + assert [u.kind for u in found] == ["boot"] + with patch.object(_audit_sources, "_run", return_value=_done("{not json")): + assert boots(DAY) == [] + with patch.object(_audit_sources, "_run", return_value=_done("", 1)): + assert boots(DAY) == [] + with patch.object(_audit_sources, "_run", side_effect=OSError("no journal")): + assert boots(DAY) == [] + + +def test_resumes_from_kernel_lines() -> None: + lines = [ + json.dumps( + { + "__REALTIME_TIMESTAMP": str(_usec(2026, 10, 1, 6, 50)), + "MESSAGE": "ACPI: PM: Waking up from system sleep state S4", + } + ), + json.dumps({"__REALTIME_TIMESTAMP": str(_usec(2026, 10, 1, 7)), "MESSAGE": ""}), + json.dumps({"MESSAGE": "no stamp"}), + "garbage", + ] + run = MagicMock(return_value=_done("\n".join(lines))) + with patch.object(_audit_sources, "_run", run): + found = resumes(DAY) + assert [u.kind for u in found] == ["resume S4", "resume ?"] + cmd = run.call_args.args[0] + assert "_TRANSPORT=kernel" in cmd + assert "-k" not in cmd + + +def test_real_seam() -> None: + with patch.object(subprocess, "run", return_value=_done("ok")) as run: + assert REAL_RUN(["journalctl"]).stdout == "ok" + run.assert_called_once() + + +def test_what_emit_writes_is_what_the_audit_reads() -> None: + """The real JSONL writer into the real reader: the `event` key contract.""" + handler = build_handler(_audit_sources.LOG_JSONL_FILE) + root = logging.getLogger() + root.addHandler(handler) + previous = root.level + root.setLevel(logging.INFO) + try: + emit("rtc_armed", "wake at 06:50", target_date="2026-10-01", epoch=5) + finally: + root.removeHandler(handler) + root.setLevel(previous) + handler.close() + named, seen = read_pc_log(datetime.now().astimezone().date()) + assert [(e.event, e.fields) for e in named] == [ + ("rtc_armed", {"target_date": "2026-10-01", "epoch": 5}) + ] + assert len(seen) == 1 diff --git a/wake_alarm/tests/test_enforcement_payload.py b/wake_alarm/tests/test_enforcement_payload.py new file mode 100644 index 0000000..e3cf09b --- /dev/null +++ b/wake_alarm/tests/test_enforcement_payload.py @@ -0,0 +1,87 @@ +"""Merging the v2 fields across devices.""" + +from __future__ import annotations + +import json + +from wake_alarm._enforcement_payload import ( + EnforcementView, + enforcement_view, + is_honoured, +) +from wake_alarm._enforcement_records import Escape + +REASON = "the alarm rang at the wrong time because the sync broke again" + + +def _log(**fields: tuple[object, str]) -> str: + return json.dumps( + {"alarm": {"fields": {k: [v, hlc] for k, (v, hlc) in fields.items()}}} + ) + + +def _escape(ident: str, day: str = "2026-10-01", reason: str = REASON) -> dict: + return {"id": ident, "date": day, "at": f"{day}T07:00:00", "reason": reason} + + +def test_empty_and_unparsable_logs_give_an_empty_view() -> None: + view = enforcement_view([("a", "{bad"), ("b", "[]"), ("c", _log())]) + assert view == EnforcementView() + assert not view.ring_started_on("2026-10-01") + assert view.escape_for("2026-10-01") is None + + +def test_newest_hlc_wins_for_ring_started_and_stick_state() -> None: + old = _log( + ring_started=({"date": "2026-09-30", "at": "x"}, "2026-09-30T05:00:00Z-0-a"), + stick_state=({"streak": 1}, "2026-09-30T05:00:00Z-0-a"), + ) + new = _log( + ring_started=({"date": "2026-10-01", "at": "y"}, "2026-10-01T05:00:00Z-0-b"), + stick_state=({"streak": 0, "last_miss": None}, "2026-10-01T05:00:00Z-0-b"), + phone_events=("not a list", "2026-10-01T05:00:00Z-0-b"), + ) + view = enforcement_view( + [("a", old), ("b", new), ("c", _log(ring_started=(1, "z")))] + ) + assert view.ring_started_on("2026-10-01") + assert view.stick_state is not None + assert view.stick_state.streak == 0 + + +def test_escapes_and_events_are_a_union_deduped() -> None: + event = {"at": "2026-10-01T06:00:00", "kind": "boot", "data": {}} + a = _log( + escapes=([_escape("e1"), _escape("e2"), "junk"], "2026-10-01T06:00:00Z-0-a"), + phone_events=([event], "2026-10-01T06:00:00Z-0-a"), + ) + b = _log( + escapes=([_escape("e1")], "2026-10-01T05:00:00Z-0-b"), + phone_events=([event, {"kind": "x"}], "2026-10-01T05:00:00Z-0-b"), + ) + view = enforcement_view([("b", b), ("a", a)]) + assert [e.id for e in view.escapes] == ["e1", "e2"] + assert len(view.phone_events) == 1 + + +def test_only_honoured_escapes_count() -> None: + raw = _log( + escapes=( + [ + _escape("short", reason="too short"), + _escape("late", day="2026-10-16"), + _escape("ok", day="2026-10-15"), + ], + "h", + ) + ) + view = enforcement_view([("a", raw)]) + assert [e.id for e in view.honoured_escapes()] == ["ok"] + assert view.escaped_dates() == frozenset({"2026-10-15"}) + assert view.escape_for("2026-10-16") is None + assert view.escape_for("2026-10-15") is not None + + +def test_is_honoured_rejects_a_bad_date() -> None: + assert not is_honoured(Escape("x", "someday", "", REASON)) + assert not is_honoured(Escape("x", "2026-10-01", "", " " * 50)) diff --git a/wake_alarm/tests/test_enforcement_records.py b/wake_alarm/tests/test_enforcement_records.py new file mode 100644 index 0000000..4a044aa --- /dev/null +++ b/wake_alarm/tests/test_enforcement_records.py @@ -0,0 +1,78 @@ +"""Single-value builders for the v2 fields.""" + +from __future__ import annotations + +from wake_alarm._enforcement_records import ( + Escape, + PhoneEvent, + RingStarted, + StickState, + escape_from, + is_iso_date, + optional_text, + parse_instant, + phone_event_from, + ring_started_from, + stick_state_from, + text, +) + + +def test_text_never_turns_null_into_none_string() -> None: + assert text(None) == "" + assert text(5) == "" + assert text("x") == "x" + assert optional_text(None) is None + assert optional_text("") is None + assert optional_text("2026-10-01") == "2026-10-01" + + +def test_is_iso_date() -> None: + assert is_iso_date("2026-10-01") + assert not is_iso_date("2026-1-1x") + assert not is_iso_date("20261001") + + +def test_parse_instant() -> None: + aware = parse_instant("2026-10-01T06:00:00+02:00") + assert aware is not None + assert aware.utcoffset() is not None + naive = parse_instant("2026-10-01T06:00:00") + assert naive is not None + assert naive.tzinfo is not None + assert parse_instant("soon") is None + + +def test_ring_started() -> None: + assert ring_started_from({"date": "2026-10-01", "at": "x"}) == RingStarted( + "2026-10-01", "x" + ) + assert ring_started_from({"date": None, "at": "x"}) is None + assert ring_started_from("2026-10-01") is None + + +def test_escape() -> None: + raw = {"id": "e1", "date": "2026-10-01", "at": None, "reason": "r"} + assert escape_from(raw) == Escape("e1", "2026-10-01", "", "r") + assert escape_from({**raw, "id": None}) is None + assert escape_from({**raw, "date": "yesterday"}) is None + assert escape_from([raw]) is None + + +def test_stick_state() -> None: + raw = {"streak": 2, "locked_through": None, "last_miss": "2026-10-01"} + assert stick_state_from(raw) == StickState(2, None, "2026-10-01") + for bad in (True, -1, "2", None): + assert stick_state_from({**raw, "streak": bad}) is None + assert stick_state_from(None) is None + + +def test_phone_event() -> None: + raw = {"at": "2026-10-01T06:00:00", "kind": "dismissed", "data": {"a": 1}} + assert phone_event_from(raw) == PhoneEvent(raw["at"], "dismissed", {"a": 1}) + assert phone_event_from({**raw, "data": None}) == PhoneEvent( + raw["at"], "dismissed", {} + ) + assert phone_event_from({**raw, "kind": None}) is None + assert phone_event_from({**raw, "at": ""}) is None + assert phone_event_from(7) is None diff --git a/wake_alarm/tests/test_escapes.py b/wake_alarm/tests/test_escapes.py new file mode 100644 index 0000000..e83df11 --- /dev/null +++ b/wake_alarm/tests/test_escapes.py @@ -0,0 +1,125 @@ +"""Escapes on the PC: escape_seen once, penalties cancelled, refresh wiring.""" + +from __future__ import annotations + +from datetime import date, datetime +import json +import logging +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import patch + +from wake_alarm import _session, _workday_penalty +from wake_alarm._enforcement_payload import EnforcementView +from wake_alarm._enforcement_records import Escape +from wake_alarm._escapes import note_escapes +from wake_alarm._events import last_value +from wake_alarm._sync import Alarm +from wake_alarm._workday_penalty import ( + cancel_for_escapes, + issued_date, + maybe_apply, + penalty_entry, + write_penalty, +) + +if TYPE_CHECKING: + import pytest + +REASON = "the alarm never rang because the phone rebooted overnight" +TUESDAY = date(2026, 9, 22) +NOON = datetime(2026, 9, 22, 12, 0).astimezone() + + +def _escape(ident: str, day: str = "2026-09-22") -> Escape: + return Escape(ident, day, f"{day}T08:00:00", REASON) + + +class TestNoteEscapes: + def test_each_escape_is_logged_once(self, caplog: pytest.LogCaptureFixture) -> None: + view = EnforcementView(escapes=(_escape("a"), _escape("b"))) + with caplog.at_level(logging.INFO): + assert note_escapes(view) == ["a", "b"] + assert note_escapes(view) == [] + assert caplog.text.count("escape_seen:") == 2 + assert last_value("escape_seen") == "a,b" + + def test_seen_ids_are_pruned_to_what_the_phone_carries(self) -> None: + note_escapes(EnforcementView(escapes=(_escape("a"),))) + note_escapes(EnforcementView(escapes=(_escape("b"),))) + assert last_value("escape_seen") == "b" + note_escapes(EnforcementView()) + assert last_value("escape_seen") is None + + def test_an_unhonoured_escape_is_ignored(self) -> None: + short = Escape("s", "2026-09-22", "x", "too short") + assert note_escapes(EnforcementView(escapes=(short,))) == [] + + +class TestPenalty: + def test_escaped_day_gets_no_penalty(self) -> None: + with patch.object(_workday_penalty.freedays, "is_free_day", return_value=False): + assert not maybe_apply( + {"outcome": "failed"}, NOON, escaped_dates=frozenset({"2026-09-22"}) + ) + assert issued_date() is None + + def test_penalty_written_is_logged_once_per_day( + self, caplog: pytest.LogCaptureFixture + ) -> None: + with ( + patch.object(_workday_penalty.freedays, "is_free_day", return_value=False), + caplog.at_level(logging.INFO), + ): + assert maybe_apply({"outcome": "failed"}, NOON) + assert maybe_apply({"outcome": "failed"}, NOON) + assert caplog.text.count("penalty_written:") == 1 + + def test_a_failed_write_logs_nothing( + self, caplog: pytest.LogCaptureFixture + ) -> None: + with ( + patch.object(_workday_penalty.freedays, "is_free_day", return_value=False), + patch.object(Path, "write_text", side_effect=OSError("ro")), + caplog.at_level(logging.INFO), + ): + assert not maybe_apply({"outcome": "failed"}, NOON) + assert "penalty_written" not in caplog.text + + def test_cancel_removes_only_the_escaped_days_penalty( + self, caplog: pytest.LogCaptureFixture + ) -> None: + assert not cancel_for_escapes(frozenset({"2026-09-22"})) # nothing on disk + write_penalty(penalty_entry(today=TUESDAY)) + assert not cancel_for_escapes(frozenset({"2026-09-23"})) + with caplog.at_level(logging.INFO): + assert cancel_for_escapes(frozenset({"2026-09-22"})) + assert issued_date() is None + assert "penalty_cancelled" in caplog.text + + def test_cancel_failure_is_false(self) -> None: + write_penalty(penalty_entry(today=TUESDAY)) + with patch.object(Path, "unlink", side_effect=OSError("ro")): + assert not cancel_for_escapes(frozenset({"2026-09-22"})) + + def test_issued_date_tolerates_garbage(self) -> None: + _workday_penalty.WORKDAY_PENALTY_FILE.write_text("{bad") + assert issued_date() is None + _workday_penalty.WORKDAY_PENALTY_FILE.write_text(json.dumps([1])) + assert issued_date() is None + _workday_penalty.WORKDAY_PENALTY_FILE.write_text(json.dumps({"issued_date": 5})) + assert issued_date() is None + + +def test_refresh_cancels_a_penalty_the_morning_after() -> None: + """Escape for Thursday arrives Friday: Thursday's penalty goes.""" + write_penalty(penalty_entry(today=date(2026, 9, 24))) + escape = {"id": "e", "date": "2026-09-24", "at": "x", "reason": REASON} + raw = json.dumps({"alarm": {"fields": {"escapes": [[escape], "h"]}}}) + friday = datetime(2026, 9, 25, 9, 0).astimezone() + with ( + patch.object(_session, "fetch_firebase_logs", return_value=[("p", raw)]), + patch.object(_session, "get_alarm", return_value=Alarm(7, 0, "cache")), + ): + assert _session.refresh(friday) is not None + assert issued_date() is None diff --git a/wake_alarm/tests/test_events.py b/wake_alarm/tests/test_events.py new file mode 100644 index 0000000..8381376 --- /dev/null +++ b/wake_alarm/tests/test_events.py @@ -0,0 +1,70 @@ +"""Named events and the change-only state behind them.""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import patch + +from wake_alarm import _events +from wake_alarm._events import emit, emit_on_change, last_value, remember + +if TYPE_CHECKING: + import pytest + + +def _records(caplog: pytest.LogCaptureFixture) -> list[logging.LogRecord]: + return [r for r in caplog.records if hasattr(r, "event")] + + +def test_emit_carries_event_and_fields(caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.INFO): + emit("rtc_armed", "wake at 06:50", epoch=5) + (record,) = _records(caplog) + assert record.event == "rtc_armed" + assert record.fields == {"epoch": 5} + assert "rtc_armed: wake at 06:50" in record.getMessage() + + +def test_emit_on_change_logs_once_per_key(caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.INFO): + assert emit_on_change("fallback_tick", "a", "first") + assert not emit_on_change("fallback_tick", "a", "again") + assert emit_on_change("fallback_tick", "b", "changed") + assert [r.getMessage() for r in _records(caplog)] == [ + "fallback_tick: first", + "fallback_tick: changed", + ] + assert last_value("fallback_tick") == "b" + + +def test_remember_and_forget() -> None: + remember("slot", "x") + remember("slot", "x") # unchanged: no rewrite needed + assert last_value("slot") == "x" + remember("slot", None) + assert last_value("slot") is None + remember("slot", None) # forgetting nothing is a no-op + assert not _events.EVENT_STATE_FILE.read_text().count("slot") + + +def test_unreadable_or_odd_state_reads_empty() -> None: + _events.EVENT_STATE_FILE.write_text("{nope") + assert last_value("slot") is None + _events.EVENT_STATE_FILE.write_text("[1, 2]") + assert last_value("slot") is None + _events.EVENT_STATE_FILE.write_text(json.dumps({"a": "x", "b": 3})) + assert last_value("a") == "x" + assert last_value("b") is None + + +def test_write_failure_only_costs_a_repeat(caplog: pytest.LogCaptureFixture) -> None: + with ( + patch.object(Path, "write_text", side_effect=OSError("ro")), + caplog.at_level(logging.INFO), + ): + remember("slot", "x") + assert last_value("slot") is None + assert "Cannot persist" in caplog.text diff --git a/wake_alarm/tests/test_fallback.py b/wake_alarm/tests/test_fallback.py new file mode 100644 index 0000000..faf3d7a --- /dev/null +++ b/wake_alarm/tests/test_fallback.py @@ -0,0 +1,175 @@ +"""The fallback tick, the ring lifecycle and the CLI.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import json +import logging +import signal +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from wake_alarm import _fallback, _fallback_observe +from wake_alarm._events import last_value +from wake_alarm._fallback_latch import mark_started, read_latch +from wake_alarm._fallback_observe import Observation +from wake_alarm._fallback_policy import Decision, Facts +from wake_alarm._sync import Alarm + +if TYPE_CHECKING: + from collections.abc import Callable, Generator + +# Bound at import, before the autouse fixture patches it. +REAL_NOW = _fallback._now +DAY = "2026-10-01" +A = datetime(2026, 10, 1, 7, 0).astimezone() + + +def _session_log() -> list[tuple[str, str]]: + session = {"date": DAY, "started_at": f"{DAY}T07:20:00", "outcome": "active"} + raw = {"alarm": {"fields": {"current_session": [session, "h"]}}} + return [("p", json.dumps(raw))] + + +@pytest.fixture(autouse=True) +def world() -> Generator[MagicMock]: + """Alarm 07:00, a tiny WAV pair, and a ring that ends on its first poll.""" + with ( + patch.object(_fallback_observe, "get_alarm", return_value=Alarm(7, 0, "c")), + patch.object(_fallback, "get_alarm", return_value=Alarm(7, 0, "c")), + patch.object(_fallback, "ensure_pair", return_value=("r", "l")), + patch.object(_fallback, "_now", return_value=A + timedelta(minutes=20)), + patch.object(_fallback, "ring") as ring, + ): + + def fake_ring(poll: Callable[[], str | None], **_: object) -> str: + return poll() or "cap" + + ring.side_effect = fake_ring + yield ring + + +def _tick(minutes: float, published: list[tuple[str, str]] | None = None) -> Decision: + with patch.object(_fallback_observe, "fetch_firebase_logs", return_value=published): + return _fallback.tick(A + timedelta(minutes=minutes)) + + +def test_waiting_logs_the_decision_once(caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.INFO): + assert _tick(1, []) == Decision(ring=False, reason="waiting") + _tick(1.5, []) + assert caplog.text.count("fallback_tick:") == 1 + assert last_value("fallback_tick") == f"{DAY}|False|waiting" + + +def test_phone_silent_rings_until_the_session_starts( + world: MagicMock, caplog: pytest.LogCaptureFixture +) -> None: + with ( + patch.object( + _fallback_observe, "fetch_firebase_logs", side_effect=[[], _session_log()] + ), + caplog.at_level(logging.INFO), + ): + decision = _fallback.tick(A + timedelta(minutes=3)) + assert decision == Decision(ring=True, reason="phone_silent") + world.assert_called_once() + latch = read_latch(DAY) + assert latch is not None + assert latch.stop_reason == "session_started" + assert "fallback_ring_start" in caplog.text + assert "fallback_ring_stop" in caplog.text + # Final for the day, even if Firebase goes quiet afterwards. + assert _tick(25, None) == Decision(ring=False, reason="session_started") + + +def test_an_open_latch_is_closed_when_the_decision_goes_quiet() -> None: + mark_started(None, day=DAY, now=A + timedelta(minutes=2), reason="phone_silent") + assert _tick(10, _session_log()) == Decision(ring=False, reason="session_started") + latch = read_latch(DAY) + assert latch is not None + assert latch.stop_reason == "session_started" + + +def test_a_crash_mid_ring_still_records_the_stop(world: MagicMock) -> None: + world.side_effect = SystemExit(143) + with pytest.raises(SystemExit): + _tick(3, []) + latch = read_latch(DAY) + assert latch is not None + assert latch.stop_reason == "terminated" + + +def test_terminate_handler_raises_system_exit() -> None: + with pytest.raises(SystemExit) as raised: + _fallback._terminate(signal.SIGTERM, None) + assert raised.value.code == 128 + signal.SIGTERM + + +def test_describe_and_show() -> None: + facts = Facts(now=A, alarm_at=A, free_day=False) + text = _fallback.describe( + Observation(facts, reachable=False), Decision(ring=True, reason="x") + ) + assert "WOULD RING: x" in text + assert "UNREACHABLE" in text + assert "quiet: y" in _fallback.describe( + Observation(facts), Decision(ring=False, reason="y") + ) + assert "A=07:00" in _fallback.show(A) + + +class TestCli: + def test_show(self, capsys: pytest.CaptureFixture[str]) -> None: + assert _fallback.main(["--show"]) == 0 + assert "latch=None" in capsys.readouterr().out + + def test_dry_run_at_changes_nothing( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with patch.object(_fallback_observe, "fetch_firebase_logs", return_value=[]): + assert _fallback.main(["--dry-run", "--at", f"{DAY}T07:05:00"]) == 0 + assert "WOULD RING: phone_silent" in capsys.readouterr().out + assert read_latch(DAY) is None + assert last_value("fallback_tick") is None + + def test_at_needs_dry_run(self) -> None: + with pytest.raises(SystemExit): + _fallback.main(["--tick", "--at", f"{DAY}T07:05:00"]) + + def test_tick_configures_logging_and_signals(self) -> None: + with ( + patch.object(_fallback, "configure_logging") as configure, + patch.object(_fallback.signal, "signal") as install, + patch.object(_fallback, "tick") as tick, + patch("sys.argv", ["prog", "--tick"]), + ): + assert _fallback.main() == 0 + configure.assert_called_once_with(component="fallback") + install.assert_called_once_with(signal.SIGTERM, _fallback._terminate) + tick.assert_called_once() + + def test_real_now_is_aware(self) -> None: + assert REAL_NOW().tzinfo is not None + + +def test_a_ring_that_keeps_going_until_the_cap() -> None: + """The first poll finds nothing to stop for; the fake then hits the cap.""" + assert _tick(3, []) == Decision(ring=True, reason="phone_silent") + latch = read_latch(DAY) + assert latch is not None + assert latch.stop_reason == "cap" + assert last_value("fallback_settled") is None + + +def test_a_wav_failure_still_closes_the_latch() -> None: + with ( + patch.object(_fallback, "ensure_pair", side_effect=OSError("disk full")), + pytest.raises(OSError, match="disk full"), + ): + _tick(3, []) + latch = read_latch(DAY) + assert latch is not None + assert latch.stop_reason == "terminated" diff --git a/wake_alarm/tests/test_fallback_latch.py b/wake_alarm/tests/test_fallback_latch.py new file mode 100644 index 0000000..89db44e --- /dev/null +++ b/wake_alarm/tests/test_fallback_latch.py @@ -0,0 +1,73 @@ +"""The fallback ring's per-day latch file.""" + +from __future__ import annotations + +from datetime import datetime +import json +from pathlib import Path +from unittest.mock import patch + +from wake_alarm import _fallback_latch +from wake_alarm._fallback_latch import ( + Latch, + mark_started, + mark_stopped, + read_latch, + write_latch, +) + +DAY = "2026-10-01" +NOW = datetime(2026, 10, 1, 7, 2).astimezone() + + +def test_absent_latch_is_none() -> None: + assert read_latch(DAY) is None + + +def test_unreadable_or_other_days_latch_is_none() -> None: + _fallback_latch.FALLBACK_RING_FILE.write_text("{nope") + assert read_latch(DAY) is None + _fallback_latch.FALLBACK_RING_FILE.write_text(json.dumps({"date": "2026-09-30"})) + assert read_latch(DAY) is None + _fallback_latch.FALLBACK_RING_FILE.write_text("[]") + assert read_latch(DAY) is None + + +def test_start_then_stop_round_trips_the_contract_shape() -> None: + latch = mark_started(None, day=DAY, now=NOW, reason="phone_silent") + assert read_latch(DAY) == latch + assert latch.started() == NOW + stopped = mark_stopped(latch, now=NOW, stop_reason="cap") + on_disk = json.loads(_fallback_latch.FALLBACK_RING_FILE.read_text()) + assert set(on_disk) == {"date", "started_at", "stopped_at", "reason", "stop_reason"} + assert read_latch(DAY) == stopped + assert stopped.stop_reason == "cap" + + +def test_a_resumed_ring_keeps_its_first_start() -> None: + first = mark_started(None, day=DAY, now=NOW, reason="phone_silent") + died = mark_stopped(first, now=NOW, stop_reason="terminated") + later = datetime(2026, 10, 1, 7, 20).astimezone() + resumed = mark_started(died, day=DAY, now=later, reason="no_session") + assert resumed.started_at == first.started_at + assert resumed.reason == "phone_silent" + assert resumed.stopped_at is None + assert resumed.stop_reason is None + + +def test_a_latch_without_a_start_starts_fresh() -> None: + latch = mark_started(Latch(date=DAY), day=DAY, now=NOW, reason="no_session") + assert latch.started_at == NOW.isoformat() + assert Latch(date=DAY).started() is None + + +def test_write_failure_is_false() -> None: + with patch.object(Path, "write_text", side_effect=OSError("ro")): + assert not write_latch(Latch(date=DAY)) + + +def test_garbage_fields_read_as_none() -> None: + _fallback_latch.FALLBACK_RING_FILE.write_text( + json.dumps({"date": DAY, "started_at": None, "stopped_at": 5, "reason": ""}) + ) + assert read_latch(DAY) == Latch(date=DAY) diff --git a/wake_alarm/tests/test_fallback_observe.py b/wake_alarm/tests/test_fallback_observe.py new file mode 100644 index 0000000..af989c5 --- /dev/null +++ b/wake_alarm/tests/test_fallback_observe.py @@ -0,0 +1,141 @@ +"""Gathering the fallback's facts, and the bookkeeping they trigger.""" + +from __future__ import annotations + +from datetime import datetime +import json +import logging +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from wake_alarm import _fallback_observe +from wake_alarm._events import last_value, remember +from wake_alarm._fallback_latch import Latch, mark_started, mark_stopped +from wake_alarm._fallback_observe import ( + SETTLED_SLOT, + observe, + record, + remember_final, + settled_reason, +) +from wake_alarm._sync import Alarm + +if TYPE_CHECKING: + from collections.abc import Generator + +DAY = "2026-10-01" +NOW = datetime(2026, 10, 1, 7, 10).astimezone() +REASON = "the phone was in a drawer charging and never rang this morning" + + +def _log(**fields: object) -> str: + return json.dumps({"alarm": {"fields": {k: [v, "h"] for k, v in fields.items()}}}) + + +@pytest.fixture(autouse=True) +def _alarm() -> Generator[None]: + with patch.object( + _fallback_observe, "get_alarm", return_value=Alarm(7, 0, "cache") + ): + yield + + +def test_outside_the_window_never_fetches() -> None: + fetch = MagicMock() + with patch.object(_fallback_observe, "fetch_firebase_logs", fetch): + seen = observe(datetime(2026, 10, 1, 5, 0).astimezone()) + fetch.assert_not_called() + assert seen.reachable + assert seen.view is None + + +def test_a_free_day_never_fetches() -> None: + fetch = MagicMock() + with ( + patch.object(_fallback_observe, "fetch_firebase_logs", fetch), + patch.object(_fallback_observe.freedays, "is_free_day", return_value=True), + ): + assert observe(NOW).facts.free_day + fetch.assert_not_called() + + +def test_unreachable_firebase_is_no_ping_no_session() -> None: + with patch.object(_fallback_observe, "fetch_firebase_logs", return_value=None): + seen = observe(NOW) + assert not seen.reachable + facts = seen.facts + assert not (facts.escaped) + assert not (facts.session_started) + assert not (facts.ring_started) + + +def test_facts_from_the_phone() -> None: + session = {"date": DAY, "started_at": f"{DAY}T07:05:00", "outcome": "active"} + raw = _log( + current_session=session, + ring_started={"date": DAY, "at": f"{DAY}T07:00:05"}, + escapes=[{"id": "e", "date": DAY, "at": "x", "reason": REASON}], + ) + with patch.object( + _fallback_observe, "fetch_firebase_logs", return_value=[("p", raw)] + ): + seen = observe(NOW) + assert seen.facts.session_started + assert seen.facts.ring_started + assert seen.facts.escaped + assert seen.view is not None + + +def test_yesterdays_session_does_not_count() -> None: + session = {"date": "2026-09-30", "started_at": "x", "outcome": "completed"} + with patch.object( + _fallback_observe, + "fetch_firebase_logs", + return_value=[("p", _log(morning_sessions=[session]))], + ): + assert not observe(NOW).facts.session_started + + +def test_settled_from_latch_or_event_state() -> None: + assert settled_reason(DAY, None) is None + latch = mark_stopped( + mark_started(None, day=DAY, now=NOW, reason="phone_silent"), + now=NOW, + stop_reason="cap", + ) + assert settled_reason(DAY, latch) == "cap" + assert settled_reason(DAY, Latch(date=DAY, stop_reason="terminated")) is None + remember_final(DAY, "waiting") + assert last_value(SETTLED_SLOT) is None + remember_final(DAY, "escape") + assert settled_reason(DAY, None) == "escape" + assert settled_reason("2026-10-02", None) is None + remember(SETTLED_SLOT, f"{DAY}|") + assert settled_reason(DAY, None) is None + + +def test_record_logs_unreachable_once_and_resets( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + patch.object(_fallback_observe, "fetch_firebase_logs", return_value=None), + caplog.at_level(logging.INFO), + ): + seen = observe(NOW) + record(seen) + record(seen) + assert caplog.text.count("firebase_unreachable:") == 1 + with patch.object(_fallback_observe, "fetch_firebase_logs", return_value=[]): + record(observe(NOW)) + assert last_value("firebase_unreachable") is None + + +def test_record_notes_escapes() -> None: + raw = _log(escapes=[{"id": "e", "date": DAY, "at": "x", "reason": REASON}]) + with patch.object( + _fallback_observe, "fetch_firebase_logs", return_value=[("p", raw)] + ): + record(observe(NOW)) + assert last_value("escape_seen") == "e" diff --git a/wake_alarm/tests/test_fallback_policy.py b/wake_alarm/tests/test_fallback_policy.py new file mode 100644 index 0000000..1c41774 --- /dev/null +++ b/wake_alarm/tests/test_fallback_policy.py @@ -0,0 +1,162 @@ +"""The fallback alarm's table, row by row and at every boundary.""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta + +import pytest + +from wake_alarm._fallback_latch import Latch +from wake_alarm._fallback_policy import ( + CAP, + ESCAPE, + FREE_DAY, + NO_SESSION, + OUTSIDE_WINDOW, + PHONE_SILENT, + SESSION_STARTED, + WAITING, + Decision, + Facts, + alarm_instant, + decide, + is_capped, + needs_facts, + stop_reason, +) +from wake_alarm._sync import Alarm + +DAY = date(2026, 10, 1) +A = datetime(2026, 10, 1, 7, 0).astimezone() + + +def _at(minutes: float) -> datetime: + return A + timedelta(minutes=minutes) + + +def _facts(minutes: float, **overrides: object) -> Facts: + return Facts(now=_at(minutes), alarm_at=A, free_day=False, **overrides) + + +def _latch(started_minutes: float, **overrides: object) -> Latch: + return Latch( + date=DAY.isoformat(), started_at=_at(started_minutes).isoformat(), **overrides + ) + + +class TestAlarmInstant: + def test_earlier_alarm_is_kept(self) -> None: + assert ( + alarm_instant(Alarm(6, 30, "remote"), DAY) + == datetime(2026, 10, 1, 6, 30).astimezone() + ) + + def test_later_alarm_is_clamped_to_eight(self) -> None: + assert alarm_instant(Alarm(13, 0, "cache"), DAY).hour == 8 + + +class TestWindow: + @pytest.mark.parametrize( + ("minutes", "inside"), + [(-5.01, False), (-5, True), (0, True), (44.9, True), (45, False)], + ) + def test_window_is_a_minus_5_to_a_plus_45( + self, minutes: float, *, inside: bool + ) -> None: + assert needs_facts(_at(minutes), A, None) is inside + + def test_a_ring_already_started_is_judged_outside_the_window(self) -> None: + assert needs_facts(_at(60), A, _latch(40)) + + def test_outside_window_never_rings(self) -> None: + assert decide(_facts(-10)) == Decision(ring=False, reason=OUTSIDE_WINDOW) + assert decide(_facts(50)) == Decision(ring=False, reason=OUTSIDE_WINDOW) + + +class TestTable: + def test_free_day_wins_over_everything(self) -> None: + facts = Facts(now=_at(20), alarm_at=A, free_day=True) + assert decide(facts) == Decision(ring=False, reason=FREE_DAY) + + def test_waiting_before_a_plus_2(self) -> None: + assert decide(_facts(1.9)) == Decision(ring=False, reason=WAITING) + + def test_phone_silent_from_a_plus_2(self) -> None: + assert decide(_facts(2)) == Decision(ring=True, reason=PHONE_SILENT) + + def test_ring_started_waits_for_the_session_grace(self) -> None: + assert decide(_facts(14.9, ring_started=True)) == Decision( + ring=False, reason=WAITING + ) + assert decide(_facts(15, ring_started=True)) == Decision( + ring=True, reason=NO_SESSION + ) + + def test_escape_stops_everything(self) -> None: + assert decide(_facts(20, escaped=True)) == Decision(ring=False, reason=ESCAPE) + + def test_session_started_stops_everything(self) -> None: + assert decide(_facts(20, session_started=True)) == Decision( + ring=False, reason=SESSION_STARTED + ) + + def test_escape_beats_session(self) -> None: + facts = _facts(20, escaped=True, session_started=True) + assert decide(facts).reason == ESCAPE + + def test_unreachable_firebase_fails_toward_ringing(self) -> None: + # The caller models "unreachable" as all-False facts. + assert decide(_facts(3)).ring + + def test_a_settled_final_reason_holds_when_firebase_goes_quiet(self) -> None: + assert decide(_facts(20, settled=SESSION_STARTED)) == Decision( + ring=False, reason=SESSION_STARTED + ) + + def test_a_non_final_settled_value_is_ignored(self) -> None: + assert decide(_facts(20, settled="phone_silent")).ring + + +class TestCap: + def test_thirty_minutes_after_the_first_start(self) -> None: + assert not is_capped(_facts(31.9, latch=_latch(2))) + assert is_capped(_facts(32, latch=_latch(2))) + assert decide(_facts(32, latch=_latch(2))) == Decision(ring=False, reason=CAP) + + def test_a_cap_stop_is_final(self) -> None: + latch = _latch(2, stopped_at=_at(32).isoformat(), stop_reason=CAP) + assert is_capped(_facts(3, latch=latch)) + + def test_no_latch_or_no_start_is_not_capped(self) -> None: + assert not is_capped(_facts(40)) + assert not is_capped(_facts(40, latch=Latch(date=DAY.isoformat()))) + + def test_a_died_ring_resumes_past_the_window(self) -> None: + latch = _latch(40) + assert decide(_facts(50, latch=latch)) == Decision( + ring=True, reason=PHONE_SILENT + ) + assert decide(_facts(70, latch=latch)) == Decision(ring=False, reason=CAP) + + +class TestStopReason: + def test_keep_ringing_when_the_phone_merely_comes_back(self) -> None: + assert stop_reason(_facts(5, ring_started=True, latch=_latch(2))) is None + + @pytest.mark.parametrize( + ("overrides", "reason"), + [ + ({"escaped": True}, ESCAPE), + ({"session_started": True}, SESSION_STARTED), + ({"settled": ESCAPE}, ESCAPE), + ], + ) + def test_contract_stop_conditions( + self, overrides: dict[str, object], reason: str + ) -> None: + assert stop_reason(_facts(10, **overrides)) == reason + + def test_free_day_and_cap_stop(self) -> None: + free = Facts(now=_at(10), alarm_at=A, free_day=True) + assert stop_reason(free) == FREE_DAY + assert stop_reason(_facts(33, latch=_latch(2))) == CAP diff --git a/wake_alarm/tests/test_fallback_ring.py b/wake_alarm/tests/test_fallback_ring.py new file mode 100644 index 0000000..33a9532 --- /dev/null +++ b/wake_alarm/tests/test_fallback_ring.py @@ -0,0 +1,127 @@ +"""The ring loop: polling cadence, backend fallback, always silenced.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from wake_alarm import _fallback_ring +from wake_alarm._fallback_ring import BACKEND_FAILED, Cadence, ring + +if TYPE_CHECKING: + from collections.abc import Iterator + +RISE, LOOP = Path("rise.wav"), Path("loop.wav") + + +class FakeBackend: + def __init__(self, name: str, *, start_ok: bool = True, pumps: int = 10**6) -> None: + self.name = name + self.start_ok = start_ok + self.pumps_left = pumps + self.stopped = 0 + + def start(self) -> tuple[bool, str]: + return self.start_ok, "detail" + + def pump(self) -> bool: + self.pumps_left -= 1 + return self.pumps_left >= 0 + + def stop(self) -> None: + self.stopped += 1 + + +class Clock: + def __init__(self) -> None: + self.t = 0.0 + + def __call__(self) -> float: + return self.t + + def sleep(self, seconds: float) -> None: + self.t += seconds + + +def _polls(*answers: str | None) -> Iterator[str | None]: + return iter(answers) + + +def _ring(backend: FakeBackend, answers: Iterator[str | None], clock: Clock) -> str: + with patch.object(_fallback_ring, "select_backend", return_value=(backend, "why")): + return ring( + lambda: next(answers), + rise=RISE, + loop=LOOP, + cadence=Cadence(clock, clock.sleep, 20), + ) + + +def test_polls_every_20_seconds_until_a_stop_reason( + caplog: pytest.LogCaptureFixture, +) -> None: + clock, backend = Clock(), FakeBackend("pipewire") + with caplog.at_level(logging.INFO): + assert _ring(backend, _polls(None, None, "session_started"), clock) == ( + "session_started" + ) + assert clock.t == pytest.approx(60, abs=1) + assert backend.stopped == 1 + assert "ring_backend: pipewire (started)" in caplog.text + + +def test_pipewire_refusing_falls_back_to_alsa() -> None: + alsa = FakeBackend("alsa") + with patch.object(_fallback_ring, "AlsaUnitBackend", return_value=alsa): + alsa.name = "alsa" + result = _ring(FakeBackend("pipewire", start_ok=False), _polls("cap"), Clock()) + assert result == "cap" + assert alsa.stopped == 1 + + +def test_no_backend_at_all() -> None: + alsa = FakeBackend("alsa", start_ok=False) + with patch.object(_fallback_ring, "AlsaUnitBackend", return_value=alsa): + assert _ring(alsa, _polls(), Clock()) == BACKEND_FAILED + + +def test_pipewire_dying_mid_ring_hands_over_to_alsa() -> None: + pipewire, alsa = FakeBackend("pipewire", pumps=3), FakeBackend("alsa") + with patch.object(_fallback_ring, "AlsaUnitBackend", return_value=alsa): + assert _ring(pipewire, _polls("escape"), Clock()) == "escape" + assert pipewire.stopped == 1 + assert alsa.stopped == 1 + + +def test_alsa_dying_mid_ring_ends_the_ring() -> None: + alsa = FakeBackend("alsa", pumps=2) + assert _ring(alsa, _polls(None), Clock()) == BACKEND_FAILED + assert alsa.stopped >= 1 + + +def test_replacement_alsa_failing_ends_the_ring() -> None: + pipewire, alsa = ( + FakeBackend("pipewire", pumps=1), + FakeBackend("alsa", start_ok=False), + ) + with patch.object(_fallback_ring, "AlsaUnitBackend", return_value=alsa): + assert _ring(pipewire, _polls(), Clock()) == BACKEND_FAILED + + +def test_an_exception_still_silences_the_backend() -> None: + backend = FakeBackend("pipewire") + + def poll() -> str | None: + raise SystemExit(143) + + clock = Clock() + with ( + patch.object(_fallback_ring, "select_backend", return_value=(backend, "w")), + pytest.raises(SystemExit), + ): + ring(poll, rise=RISE, loop=LOOP, cadence=Cadence(clock, clock.sleep, 0)) + assert backend.stopped == 1 diff --git a/wake_alarm/tests/test_pc_wake_mode.py b/wake_alarm/tests/test_pc_wake_mode.py new file mode 100644 index 0000000..4bcfcab --- /dev/null +++ b/wake_alarm/tests/test_pc_wake_mode.py @@ -0,0 +1,32 @@ +"""pc_wake.json: hibernate unless it clearly says poweroff.""" + +from __future__ import annotations + +import json + +import pytest + +from wake_alarm import _pc_wake_mode +from wake_alarm._pc_wake_mode import main, read_mode + + +def test_missing_file_is_hibernate() -> None: + assert read_mode() == "hibernate" + + +def test_poweroff_is_honoured() -> None: + _pc_wake_mode.PC_WAKE_CONFIG.write_text(json.dumps({"mode": "poweroff"})) + assert read_mode() == "poweroff" + + +@pytest.mark.parametrize( + "body", ["{bad", "[]", '{"mode": "suspend"}', '{"mode": null}'] +) +def test_anything_odd_is_hibernate(body: str) -> None: + _pc_wake_mode.PC_WAKE_CONFIG.write_text(body) + assert read_mode() == "hibernate" + + +def test_cli_prints_the_mode(capsys: pytest.CaptureFixture[str]) -> None: + assert main() == 0 + assert capsys.readouterr().out == "hibernate\n" diff --git a/wake_alarm/tests/test_ring_player.py b/wake_alarm/tests/test_ring_player.py new file mode 100644 index 0000000..d118e2f --- /dev/null +++ b/wake_alarm/tests/test_ring_player.py @@ -0,0 +1,193 @@ +"""Backend choice and the two backends, with every process faked.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from wake_alarm import _ring_player +from wake_alarm._ring_player import ( + ALSA_UNIT, + AlsaUnitBackend, + PipeWireBackend, + graphical_session, + select_backend, +) + +if TYPE_CHECKING: + from collections.abc import Callable + +# Bound at import, before conftest swaps the seams for raisers. +REAL_RUN = _ring_player._run +REAL_POPEN = _ring_player._popen +UID = str(os.getuid()) + + +def _done(stdout: str = "", code: int = 0) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess([], code, stdout=stdout, stderr="") + + +def _loginctl( + session: str, props: str +) -> Callable[[list[str]], subprocess.CompletedProcess[str]]: + def run(cmd: list[str]) -> subprocess.CompletedProcess[str]: + if cmd[1] == "show-seat": + return _done(f"ActiveSession={session}\n") + return _done(props) + + return run + + +class TestGraphicalSession: + def test_this_users_active_x11_session(self) -> None: + run = _loginctl("7", f"Type=x11\nActive=yes\nUser={UID}\n") + with patch.object(_ring_player, "_run", run): + ok, why = graphical_session() + assert ok + assert "x11 session 7" in why + + @pytest.mark.parametrize( + ("props", "expected"), + [ + (f"Type=tty\nActive=yes\nUser={UID}\n", "not an active graphical"), + (f"Type=wayland\nActive=no\nUser={UID}\n", "not an active graphical"), + ("Type=x11\nActive=yes\nUser=0\n", "belongs to uid 0"), + ("Type=x11\nActive=yes\n", "belongs to uid ?"), + ], + ) + def test_anything_else_is_not_ours(self, props: str, expected: str) -> None: + with patch.object(_ring_player, "_run", _loginctl("7", props)): + ok, why = graphical_session() + assert not ok + assert expected in why + + def test_no_active_session(self) -> None: + with patch.object(_ring_player, "_run", _loginctl("", "")): + ok, why = graphical_session() + assert not ok + assert "no active session" in why + + def test_loginctl_missing(self) -> None: + with patch.object(_ring_player, "_run", side_effect=OSError("nope")): + ok, why = graphical_session() + assert not ok + assert "nope" in why + + def test_select_backend(self) -> None: + with patch.object(_ring_player, "graphical_session", return_value=(True, "g")): + backend, why = select_backend(Path("r"), Path("l")) + assert (backend.name, why) == ("pipewire", "g") + with patch.object(_ring_player, "graphical_session", return_value=(False, "n")): + backend, why = select_backend(Path("r"), Path("l")) + assert (backend.name, why) == ("alsa", "n") + + +class TestPipeWire: + def _backend(self) -> PipeWireBackend: + return PipeWireBackend(Path("rise.wav"), Path("loop.wav")) + + def test_start_unmutes_maxes_and_plays_the_rise(self) -> None: + run = MagicMock(return_value=_done()) + proc = MagicMock() + with ( + patch.object(_ring_player, "_run", run), + patch.object(_ring_player, "_popen", return_value=proc) as popen, + ): + assert self._backend().start() == (True, "pw-play started") + assert [c.args[0][1] for c in run.call_args_list] == ["set-mute", "set-volume"] + assert popen.call_args.args[0] == ["pw-play", "--volume", "1.0", "rise.wav"] + + def test_start_fails_when_wpctl_fails(self) -> None: + with patch.object(_ring_player, "_run", return_value=_done("no sink", 1)): + ok, detail = self._backend().start() + assert not ok + assert "no sink" in detail + + def test_start_fails_when_pw_play_is_missing(self) -> None: + with ( + patch.object(_ring_player, "_run", return_value=_done()), + patch.object(_ring_player, "_popen", side_effect=OSError("enoent")), + ): + ok, detail = self._backend().start() + assert not ok + assert "enoent" in detail + + def test_pump_loops_after_the_rise_and_reports_failure(self) -> None: + backend = self._backend() + assert not backend.pump() # never started + playing, finished, errored = MagicMock(), MagicMock(), MagicMock() + playing.poll.return_value = None + finished.poll.return_value = 0 + errored.poll.return_value = 1 + with patch.object(_ring_player, "_popen", return_value=playing) as popen: + backend._proc = playing + assert backend.pump() + backend._proc = finished + assert backend.pump() + assert popen.call_args.args[0][-1] == "loop.wav" + backend._proc = errored + assert not backend.pump() + backend._proc = finished + with patch.object(_ring_player, "_popen", side_effect=OSError("gone")): + assert not backend.pump() + + def test_stop_terminates_and_kills_if_needed(self) -> None: + backend = self._backend() + backend.stop() # nothing running + proc = MagicMock() + proc.poll.return_value = None + proc.wait.side_effect = subprocess.TimeoutExpired("pw-play", 5) + backend._proc = proc + backend.stop() + proc.terminate.assert_called_once() + proc.kill.assert_called_once() + done = MagicMock() + done.poll.return_value = 0 + backend._proc = done + backend.stop() + done.terminate.assert_not_called() + + +class TestAlsaUnit: + def test_start_pump_stop(self) -> None: + run = MagicMock(return_value=_done()) + backend = AlsaUnitBackend() + backend.stop() # not started: no sudo call + with patch.object(_ring_player, "_run", run): + assert backend.start() == (True, "unit started") + assert backend.pump() + backend.stop() + backend.stop() + assert [c.args[0] for c in run.call_args_list] == [ + ["sudo", "-n", "systemctl", "start", ALSA_UNIT], + ["sudo", "-n", "systemctl", "stop", ALSA_UNIT], + ] + + def test_start_failure_and_stop_failure( + self, caplog: pytest.LogCaptureFixture + ) -> None: + backend = AlsaUnitBackend() + with patch.object(_ring_player, "_run", return_value=_done("denied", 1)): + ok, detail = backend.start() + assert not ok + assert "denied" in detail + assert not backend.pump() + backend._started = True + backend.stop() + assert "Could not stop" in caplog.text + + +def test_real_seams_call_subprocess() -> None: + with ( + patch.object(subprocess, "run", return_value=_done("x")) as run, + patch.object(subprocess, "Popen") as popen, + ): + assert REAL_RUN(["loginctl"]).stdout == "x" + REAL_POPEN(["pw-play", "x.wav"]) + assert run.call_args.args[0] == ["loginctl"] + assert popen.call_args.args[0] == ["pw-play", "x.wav"] diff --git a/wake_alarm/tests/test_ring_wav.py b/wake_alarm/tests/test_ring_wav.py new file mode 100644 index 0000000..36ea363 --- /dev/null +++ b/wake_alarm/tests/test_ring_wav.py @@ -0,0 +1,85 @@ +"""The generated ring sound.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +import wave + +from wake_alarm._ring_wav import ( + LOOP_FILENAME, + RISE_FILENAME, + SAMPLE_RATE, + ensure_pair, + main, + samples, + write_pair, +) + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +def _peak(data: list[int], start: float, seconds: float) -> int: + lo = int(start * SAMPLE_RATE) + return max(abs(v) for v in data[lo : lo + int(seconds * SAMPLE_RATE)]) + + +def test_the_rise_climbs_then_holds_full_scale() -> None: + data = list(samples(4.0, rise_seconds=2.0)) + first, middle, held = ( + _peak(data, 0, 0.5), + _peak(data, 1.0, 0.5), + _peak(data, 2.4, 1), + ) + assert 0 < first < middle < held + assert held > 30_000 + + +def test_the_loop_is_full_scale_with_pauses() -> None: + data = list(samples(1.2)) + assert _peak(data, 0, 0.12) > 30_000 + assert _peak(data, 0.85, 0.3) == 0 # the pause after four beeps + + +def test_write_pair_produces_valid_wavs(tmp_path: Path) -> None: + rise, loop = write_pair(tmp_path / "out", rise_seconds=0.5, loop_seconds=0.25) + with wave.open(str(rise)) as handle: + assert (handle.getnchannels(), handle.getsampwidth()) == (1, 2) + assert handle.getframerate() == SAMPLE_RATE + assert handle.getnframes() == SAMPLE_RATE // 2 + with wave.open(str(loop)) as handle: + assert handle.getnframes() == SAMPLE_RATE // 4 + + +def test_ensure_pair_prefers_the_installed_copy(tmp_path: Path) -> None: + system, user = tmp_path / "system", tmp_path / "user" + system.mkdir() + (system / RISE_FILENAME).write_bytes(b"r") + (system / LOOP_FILENAME).write_bytes(b"l") + assert ensure_pair(system, user) == (system / RISE_FILENAME, system / LOOP_FILENAME) + + +def test_ensure_pair_generates_when_nothing_is_installed(tmp_path: Path) -> None: + rise, loop = ensure_pair(tmp_path / "system", tmp_path / "user") + assert rise.parent == tmp_path / "user" + assert rise.is_file() + assert loop.is_file() + # A second call reuses what the first generated. + assert ensure_pair(tmp_path / "system", tmp_path / "user") == (rise, loop) + + +def test_cli_writes_into_the_directory( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert main(["--write", str(tmp_path)]) == 0 + assert capsys.readouterr().out.splitlines() == [ + str(tmp_path / RISE_FILENAME), + str(tmp_path / LOOP_FILENAME), + ] + + +def test_cli_reads_sys_argv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.argv", ["prog", "--write", str(tmp_path)]) + assert main() == 0 diff --git a/wake_alarm/tests/test_rtc.py b/wake_alarm/tests/test_rtc.py new file mode 100644 index 0000000..dc2969e --- /dev/null +++ b/wake_alarm/tests/test_rtc.py @@ -0,0 +1,203 @@ +"""RTC arming: which morning, whether to arm, and never on this host.""" + +from __future__ import annotations + +from datetime import date, datetime +import logging +import subprocess +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from wake_alarm import _rtc, _rtc_hw +from wake_alarm._rtc import Plan, apply, describe, main, plan +from wake_alarm._rtc_hw import ArmedRecord, read_record +from wake_alarm._sync import Alarm + +if TYPE_CHECKING: + from collections.abc import Generator + +REAL_NOW = _rtc._now +ALARM = Alarm(7, 0, "cache") +MONDAY_EVENING = datetime(2026, 10, 5, 22, 0).astimezone() + + +def _done(code: int = 0, err: str = "") -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess([], code, stdout="", stderr=err) + + +@pytest.fixture(autouse=True) +def _alarm() -> Generator[None]: + with patch.object(_rtc, "get_alarm", return_value=ALARM): + yield + + +class TestPlan: + def test_evening_targets_tomorrow_minus_the_lead(self) -> None: + chosen = plan(MONDAY_EVENING, ALARM) + assert chosen.target == date(2026, 10, 6) + assert chosen.wake_at == datetime(2026, 10, 6, 6, 50).astimezone() + assert chosen.arm + + def test_small_hours_target_the_same_morning(self) -> None: + chosen = plan(datetime(2026, 10, 6, 2, 0).astimezone(), ALARM) + assert chosen.target == date(2026, 10, 6) + + def test_past_the_wake_instant_targets_tomorrow(self) -> None: + chosen = plan(datetime(2026, 10, 6, 6, 50).astimezone(), ALARM) + assert chosen.target == date(2026, 10, 7) + + def test_a_later_alarm_is_clamped_to_eight(self) -> None: + chosen = plan(MONDAY_EVENING, Alarm(13, 0, "cache")) + assert chosen.wake_at.hour == 7 + assert chosen.wake_at.minute == 50 + + def test_carrot_days_are_skipped(self) -> None: + chosen = plan(datetime(2026, 10, 1, 22, 0).astimezone(), ALARM) # -> Fri + assert not chosen.arm + assert chosen.reason == "Fri is not a stick day" + + def test_free_days_are_skipped(self) -> None: + with patch.object(_rtc.freedays, "is_free_day", return_value=True): + chosen = plan(MONDAY_EVENING, ALARM) + assert (chosen.arm, chosen.reason) == (False, "free day") + + +class TestApply: + def test_armed_is_logged_once(self, caplog: pytest.LogCaptureFixture) -> None: + chosen = plan(MONDAY_EVENING, ALARM) + with ( + patch.object(_rtc_hw, "_run", return_value=_done()), + caplog.at_level(logging.INFO), + ): + assert apply(chosen) + assert apply(chosen) + assert caplog.text.count("rtc_armed:") == 1 + assert "target_date" in str(caplog.records[-1].__dict__.get("fields")) + + def test_skip_and_failure(self, caplog: pytest.LogCaptureFixture) -> None: + skip = Plan(date(2026, 10, 2), MONDAY_EVENING, arm=False, reason="free day") + with caplog.at_level(logging.INFO): + assert not apply(skip) + with patch.object(_rtc_hw, "_run", return_value=_done(1, "denied")): + assert not apply(plan(MONDAY_EVENING, ALARM)) + assert "rtc_skip: free day" in caplog.text + assert "rtcwake failed: denied" in caplog.text + + +class TestDisarm: + """A skipped morning clears the alarm we armed -- and only ours.""" + + skip = Plan(date(2026, 10, 2), MONDAY_EVENING, arm=False, reason="free day") + + def _arm_ours(self) -> int: + chosen = plan(MONDAY_EVENING, ALARM) + with patch.object(_rtc_hw, "_run", return_value=_done()): + assert apply(chosen) + _rtc_hw.WAKEALARM_FILE.write_text(str(chosen.epoch)) + return chosen.epoch + + def test_ours_is_disarmed_and_logged( + self, caplog: pytest.LogCaptureFixture + ) -> None: + epoch = self._arm_ours() + run = MagicMock(return_value=_done()) + with patch.object(_rtc_hw, "_run", run), caplog.at_level(logging.INFO): + assert not apply(self.skip) + assert run.call_args.args[0] == [ + "sudo", + "-n", + "/usr/sbin/rtcwake", + "-m", + "disable", + ] + assert "rtc_disarmed: cleared 2026-10-06 (free day)" in caplog.text + assert read_record() is None + assert epoch + + def test_someone_elses_alarm_is_left_alone( + self, caplog: pytest.LogCaptureFixture + ) -> None: + self._arm_ours() + _rtc_hw.WAKEALARM_FILE.write_text("1234") + with caplog.at_level(logging.INFO): + apply(self.skip) # the conftest seam would raise on any rtcwake + assert "rtc_disarmed" not in caplog.text + assert read_record() is None + + def test_nothing_of_ours_means_no_rtcwake(self) -> None: + _rtc_hw.WAKEALARM_FILE.write_text("1234") + assert not apply(self.skip) + + def test_a_failed_disarm_keeps_the_record( + self, caplog: pytest.LogCaptureFixture + ) -> None: + self._arm_ours() + with ( + patch.object(_rtc_hw, "_run", return_value=_done(1, "denied")), + caplog.at_level(logging.INFO), + ): + apply(self.skip) + assert "rtcwake -m disable failed: denied" in caplog.text + assert read_record() is not None + + def test_dry_run_says_what_it_would_disarm( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + epoch = self._arm_ours() + assert main(["--dry-run", "--at", "2026-10-08T22:00:00"]) == 0 # -> Fri + assert f"would disarm ours: {epoch} for 2026-10-06" in capsys.readouterr().out + _rtc_hw.WAKEALARM_FILE.write_text("1") + assert main(["--dry-run", "--at", "2026-10-08T22:00:00"]) == 0 + assert "would disarm" not in capsys.readouterr().out + assert read_record() == ArmedRecord(epoch, "2026-10-06") + + +class TestCli: + def test_dry_run_and_show(self, capsys: pytest.CaptureFixture[str]) -> None: + assert main(["--dry-run", "--at", "2026-10-05T22:00:00"]) == 0 + assert capsys.readouterr().out.startswith("ARM 2026-10-06") + epoch = plan(MONDAY_EVENING, ALARM).epoch + _rtc_hw.WAKEALARM_FILE.write_text(str(epoch)) + assert main(["--show"]) == 0 + out = capsys.readouterr().out + expected = datetime(2026, 10, 6, 6, 50).astimezone().isoformat() + assert f"rtc wakealarm: {expected}" in out + assert "mode: hibernate" in out + assert "armed by us: none" in out + _rtc_hw.WAKEALARM_FILE.unlink() + assert main(["--show"]) == 0 + assert "rtc wakealarm: none" in capsys.readouterr().out + + def test_at_needs_dry_run(self) -> None: + with pytest.raises(SystemExit): + main(["--arm", "--at", "2026-10-05T22:00:00"]) + + def test_arm_exit_codes(self) -> None: + with ( + patch.object(_rtc, "configure_logging"), + patch.object(_rtc, "_now", return_value=MONDAY_EVENING), + ): + with patch.object(_rtc_hw, "_run", return_value=_done()): + assert main(["--arm"]) == 0 + _rtc_hw.WAKEALARM_FILE.write_text("1") + with patch.object(_rtc_hw, "_run", return_value=_done(1)): + assert main(["--arm"]) == 1 + with ( + patch.object(_rtc, "configure_logging"), + patch.object( + _rtc, "_now", return_value=datetime(2026, 10, 1, 22).astimezone() + ), + patch("sys.argv", ["prog", "--arm"]), + ): + assert main() == 0 # a skip is not a failure + + def test_describe(self) -> None: + assert "skip" in describe( + Plan(date(2026, 10, 2), MONDAY_EVENING, arm=False, reason="x") + ) + + +def test_real_now_is_aware() -> None: + assert REAL_NOW().tzinfo is not None diff --git a/wake_alarm/tests/test_rtc_hw.py b/wake_alarm/tests/test_rtc_hw.py new file mode 100644 index 0000000..009c323 --- /dev/null +++ b/wake_alarm/tests/test_rtc_hw.py @@ -0,0 +1,110 @@ +"""The RTC alarm and our record of it, with sudo faked.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +from unittest.mock import MagicMock, patch + +from wake_alarm import _rtc_hw +from wake_alarm._rtc_hw import ( + ArmedRecord, + arm, + current_alarm, + disarm_ours, + read_record, +) + +# Bound at import, before conftest swaps the seam for a raiser. +REAL_RUN = _rtc_hw._run +EPOCH = 1791265560 + + +def _done(code: int = 0, err: str = "") -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess([], code, stdout="", stderr=err) + + +def test_reads_the_pending_alarm() -> None: + assert current_alarm() is None + _rtc_hw.WAKEALARM_FILE.write_text("\n") + assert current_alarm() is None + _rtc_hw.WAKEALARM_FILE.write_text(f"{EPOCH}\n") + assert current_alarm() == EPOCH + + +def test_already_set_needs_no_sudo_but_is_recorded() -> None: + _rtc_hw.WAKEALARM_FILE.write_text(str(EPOCH)) + assert arm(EPOCH, "2026-10-06") == (True, "already set") + assert read_record() == ArmedRecord(EPOCH, "2026-10-06") + + +def test_arms_through_sudo_rtcwake() -> None: + run = MagicMock(return_value=_done()) + with patch.object(_rtc_hw, "_run", run): + assert arm(EPOCH, "2026-10-06") == (True, "armed") + assert run.call_args.args[0] == [ + "sudo", + "-n", + "/usr/sbin/rtcwake", + "-m", + "no", + "-t", + str(EPOCH), + ] + assert read_record() == ArmedRecord(EPOCH, "2026-10-06") + + +def test_arm_failures_record_nothing() -> None: + with patch.object(_rtc_hw, "_run", return_value=_done(1, "denied")): + assert arm(EPOCH, "d") == (False, "denied") + with patch.object(_rtc_hw, "_run", side_effect=OSError("no sudo")): + assert arm(EPOCH, "d") == (False, "no sudo") + assert read_record() is None + + +def test_disarm_only_ours() -> None: + assert disarm_ours() == (None, False, "nothing of ours armed") + with patch.object(_rtc_hw, "_run", return_value=_done()): + arm(EPOCH, "2026-10-06") + _rtc_hw.WAKEALARM_FILE.write_text(str(EPOCH)) + run = MagicMock(return_value=_done()) + with patch.object(_rtc_hw, "_run", run): + record, disarmed, detail = disarm_ours() + assert (record, disarmed, detail) == ( + ArmedRecord(EPOCH, "2026-10-06"), + True, + "disarmed", + ) + assert run.call_args.args[0][-2:] == ["-m", "disable"] + assert read_record() is None + + +def test_a_fired_or_replaced_alarm_is_just_forgotten() -> None: + _rtc_hw.WAKEALARM_FILE.write_text(str(EPOCH)) + arm(EPOCH, "2026-10-06") + _rtc_hw.WAKEALARM_FILE.write_text("") + _, disarmed, detail = disarm_ours() + assert (disarmed, detail) == (False, "no longer pending") + assert read_record() is None + + +def test_record_tolerates_garbage() -> None: + for body in ("{bad", "[1]", '{"epoch": true}', '{"epoch": "5"}'): + _rtc_hw.RTC_ARMED_FILE.write_text(body) + assert read_record() is None + _rtc_hw.RTC_ARMED_FILE.write_text(json.dumps({"epoch": 5, "target_date": None})) + assert read_record() == ArmedRecord(5, "") + + +def test_record_write_failure_only_logs() -> None: + _rtc_hw.WAKEALARM_FILE.write_text(str(EPOCH)) + with patch.object(Path, "write_text", side_effect=OSError("ro")): + assert arm(EPOCH, "d") == (True, "already set") + assert read_record() is None + + +def test_real_seam() -> None: + with patch.object(subprocess, "run", return_value=_done()) as run: + REAL_RUN(["true"]) + assert run.call_args.args[0] == ["true"] diff --git a/wake_alarm/tests/test_stakes_mirrors.py b/wake_alarm/tests/test_stakes_mirrors.py new file mode 100644 index 0000000..1b55fac --- /dev/null +++ b/wake_alarm/tests/test_stakes_mirrors.py @@ -0,0 +1,96 @@ +"""The phone's copies of the wake-enforcement numbers agree with the PC's. + +The stakes card and the ring line (``phone_app/lib/services/stakes.dart``) +promise numbers the PC enforces, and the native stick +(``Stakes.kt``) decides with some of them; neither can import +``wake_alarm._constants``. This parses their source and compares, so a +number changed on one side fails here instead of on a morning. +See ``DOCS-wake-enforcement.md``. +""" + +from __future__ import annotations + +from datetime import date, timedelta +from pathlib import Path +import re + +from wake_alarm import _constants + +_PHONE = Path(__file__).resolve().parents[2] / "phone_app" +_DART = _PHONE / "lib/services/stakes.dart" +_KOTLIN = _PHONE / "android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/Stakes.kt" + + +def _match(text: str, pattern: str) -> re.Match[str]: + """Return *pattern*'s match in *text*, failing with the pattern when absent.""" + found = re.search(pattern, text) + assert found is not None, f"not found: {pattern}" + return found + + +def _dart_duration(name: str) -> timedelta: + """Read ``const = Duration(: );`` from ``stakes.dart``.""" + found = _match(_DART.read_text(), rf"const {name} = Duration\((\w+): (\d+)\);") + return timedelta(**{found.group(1): int(found.group(2))}) + + +def _dart_clock(name: str) -> tuple[int, int]: + """Read ``const ClockTime = (hour: h, minute: m);``.""" + pattern = rf"const ClockTime {name} = \(hour: (\d+), minute: (\d+)\);" + found = _match(_DART.read_text(), pattern) + return int(found.group(1)), int(found.group(2)) + + +def _dart_int(name: str) -> int: + """Read ``const = ;`` from ``stakes.dart``.""" + return int(_match(_DART.read_text(), rf"const {name} = (\d+);").group(1)) + + +def _kotlin(name: str) -> str: + """Read ``const val = `` from ``Stakes.kt``, unquoted.""" + found = _match(_KOTLIN.read_text(), rf"const val {name} = \"?([\w-]+?)L?\"?\n") + return found.group(1) + + +def test_dart_durations() -> None: + """Every Dart ``Duration`` equals its Python ``timedelta``.""" + assert _dart_duration("stickGrace") == _constants.SESSION_NONE_GRACE + assert _dart_duration("provisionalGrace") == _constants.SESSION_PROVISIONAL_GRACE + assert ( + _dart_duration("pcPhoneSilentAfter") == _constants.FALLBACK_PHONE_SILENT_AFTER + ) + assert _dart_duration("pcRingCap") == _constants.FALLBACK_RING_CAP + + +def test_dart_times_of_day() -> None: + """The carrot's cap and end match the PC's ``(hour, minute)`` tuples.""" + assert _dart_clock("provisionalCap") == tuple(_constants.SESSION_PROVISIONAL_CAP) + assert _dart_clock("completedUntil") == tuple(_constants.SESSION_COMPLETED_UNTIL) + + +def test_dart_counts() -> None: + """The lockdown span and the reason length match.""" + assert _dart_int("stickLockdownMaxDays") == _constants.STICK_LOCKDOWN_MAX_DAYS + assert _dart_int("escapeReasonMinChars") == _constants.ESCAPE_REASON_MIN_CHARS + + +def test_dart_escape_last_day() -> None: + """``escapeHatchLastDay`` is ``ESCAPE_HATCH_LAST_DAY``.""" + pattern = r"final escapeHatchLastDay = DateTime\((\d+), (\d+), (\d+)\);" + found = _match(_DART.read_text(), pattern) + dart = date(*(int(found.group(i)) for i in (1, 2, 3))) + assert dart == _last_day() + + +def test_kotlin_copies() -> None: + """The native stick's grace, lockdown span and hatch end match too.""" + grace = timedelta(minutes=int(_kotlin("STICK_GRACE_MINUTES"))) + assert grace == _constants.SESSION_NONE_GRACE + assert int(_kotlin("STICK_LOCKDOWN_MAX_DAYS")) == _constants.STICK_LOCKDOWN_MAX_DAYS + assert date.fromisoformat(_kotlin("ESCAPE_HATCH_LAST_DAY")) == _last_day() + + +def _last_day() -> date: + """``ESCAPE_HATCH_LAST_DAY`` as a date, whether stored as one or as ISO text.""" + value = _constants.ESCAPE_HATCH_LAST_DAY + return value if isinstance(value, date) else date.fromisoformat(str(value))