Wake enforcement v2: PC fallback ring + RTC self-wake, stacking Tue-Thu phone lockdown, escape hatch until 2026-10-15, stakes in the app, daily incident audit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-01 12:17:06 +02:00
parent 19f23f35a6
commit 728e00cde7
109 changed files with 8727 additions and 94 deletions

View File

@ -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 <dep>"`.
## 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

146
DOCS-wake-enforcement.md Normal file
View File

@ -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 <epoch>` (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": "<ISO-8601 with offset>"}` — 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/<date>.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.

View File

@ -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]"

View File

@ -84,6 +84,11 @@
<receiver
android:name=".WatchdogReceiver"
android:exported="false"/>
<!-- The stick's exact check at A + 15 (StickAlarm.kt); not exported,
only this app's own PendingIntent reaches it. -->
<receiver
android:name=".StickCheckReceiver"
android:exported="false"/>
<receiver
android:name=".BootReceiver"
android:exported="true">

View File

@ -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()
}
}
}

View File

@ -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 {

View File

@ -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].
*

View File

@ -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 =

View File

@ -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 {

View File

@ -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<String> =
prefs.getStringSet(key, emptySet())?.toSet() ?: emptySet()
}

View File

@ -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<String>("kind")
if (kind == null) {
result.error("bad_args", "kind is required", null)
} else {
val data = call.argument<Map<String, Any?>>("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<String, Any?> {
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(),
)
}
}

View File

@ -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
}
}

View File

@ -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<String, Any?> = 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)
}

View File

@ -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

View File

@ -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")
}
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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) }
}
}

View File

@ -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<DayVerdict>,
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<DayVerdict>()
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<StickVerdict, StickState> {
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
}

View File

@ -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))
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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,
)
}
}

View File

@ -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<void> 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<void> ringStartedMain() => runHeadlessRingStart();
/// Root application widget.
class WakeAlarmSyncApp extends StatelessWidget {
/// Creates a [WakeAlarmSyncApp]; [newScheduler] is the probes' seam.

View File

@ -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<String?> showEscapeDialog(BuildContext context) =>
showDialog<String>(context: context, builder: (_) => const EscapeDialog());
/// The escape-hatch dialog; see [showEscapeDialog].
class EscapeDialog extends StatefulWidget {
/// Creates the dialog.
const EscapeDialog({super.key});
@override
State<EscapeDialog> createState() => _EscapeDialogState();
}
class _EscapeDialogState extends State<EscapeDialog> {
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: <Widget>[
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: <Widget>[
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'),
),
],
);
}
}

View File

@ -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<String>('stakes-$syncEpoch'),
busy: busy,
onEscaped: onEscaped,
),
const SizedBox(height: 24),
// Not `ValueKey<int>(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

View File

@ -236,6 +236,7 @@ class _HomeScreenState extends State<HomeScreen> {
configured: _configured,
syncEpoch: _syncEpoch,
onPickEarlier: () => unawaited(_pickEarlier()),
onEscaped: () => unawaited(_syncToPc()),
),
);
}

View File

@ -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: <Widget>[
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,

View File

@ -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}',
);
}

View File

@ -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<RingScreen> {
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<RingScreen> {
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() {

View File

@ -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<RingStakesLine> createState() => _RingStakesLineState();
}
class _RingStakesLineState extends State<RingStakesLine> {
String? _line;
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _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<AppStatusColors>()?.warning,
),
),
);
}
}

View File

@ -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<AlarmTime?> 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<StakesCard> createState() => _StakesCardState();
}
class _StakesCardState extends State<StakesCard> {
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<void> _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<AlarmTime?> _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<void> _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<AppStatusColors>()?.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: <Widget>[
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'),
),
),
],
],
),
),
);
}
}

View File

@ -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<void> appendEvent(
String kind, [
Map<String, Object?> data = const <String, Object?>{},
]) => _channel.invokeMethod<void>('appendEvent', <String, Object?>{
'kind': kind,
'data': data,
});
/// The whole event log, newest first; empty when the platform has none.
Future<List<Map<String, Object?>>> readEvents() async {
final text = await _channel.invokeMethod<String>('readEvents');
if (text == null) return const <Map<String, Object?>>[];
final decoded = jsonDecode(text);
if (decoded is! List) return const <Map<String, Object?>>[];
return <Map<String, Object?>>[
for (final entry in decoded)
if (entry is Map) Map<String, Object?>.from(entry),
];
}
/// The native stick; null when the platform answers nothing usable.
Future<EnforcementState?> state() async => EnforcementState.fromMap(
await _channel.invokeMapMethod<Object?, Object?>('enforcementState'),
);
/// Marks today escaped natively, clears the streak and lifts the
/// lockdown; false when the hatch has closed.
Future<bool> escapeToday() async =>
await _channel.invokeMethod<bool>('escapeToday') ?? false;
}

View File

@ -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<Object?, Object?>? 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<String, Object?> toStickStateJson() => <String, Object?>{
'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);
}

View File

@ -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<void> 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<Map<String, Object?>> boundEvents(
List<Map<String, Object?>> events,
DateTime now,
) {
final cutoff = now.subtract(eventsMaxAge);
final dated = <(DateTime, Map<String, Object?>)>[
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];
}

View File

@ -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<bool> 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;
}
}

View File

@ -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<List<Map<String, Object?>>> load() async {
final prefs = await SharedPreferences.getInstance();
final text = prefs.getString(key);
if (text == null) return <Map<String, Object?>>[];
try {
final decoded = jsonDecode(text);
if (decoded is! List) return <Map<String, Object?>>[];
return <Map<String, Object?>>[
for (final entry in decoded)
if (entry is Map) Map<String, Object?>.from(entry),
];
} on FormatException {
return <Map<String, Object?>>[];
}
}
/// Records an escape of [now]'s morning with [reason]; returns the entry.
static Future<Map<String, Object?>> add(
String reason, {
DateTime? now,
}) async {
final at = now ?? DateTime.now();
final entry = <String, Object?>{
'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;
}
}

View File

@ -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<void> 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<void>('done');
} on Object catch (e) {
developer.log('headless done not delivered: $e', name: 'WakeAlarmRing');
}
}
}

View File

@ -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');

View File

@ -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<AlarmSyncService> Function()? open,
Future<void> 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<AlarmSyncService> Function() _open;
final Future<void> 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<bool> 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', <String, Object?>{
'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');
}

View File

@ -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<Map<String, Object?>?> 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<String, Object?>.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<Map<String, Object?>> recordFirst(DateTime now) async {
final existing = await load();
if (existing != null && existing['date'] == isoDate(now)) return existing;
final entry = <String, Object?>{
'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<bool> publishedOn(Object? date) async =>
(await _fresh()).getString(publishedKey) == date;
/// Records that [date]'s record reached the backend.
static Future<void> markPublished(String date) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(publishedKey, date);
}
static Future<SharedPreferences> _fresh() async {
final prefs = await SharedPreferences.getInstance();
await prefs.reload();
return prefs;
}
}

View File

@ -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<void> 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.

View File

@ -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);

View File

@ -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<String> stakes;
/// A lockdown, penalty or escape already in force.
final List<String> 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 = <String>[];
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<String> _pending(EnforcementState state) {
final today = state.today;
final locked = state.lockedThrough;
return <String>[
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]}';

View File

@ -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:

View File

@ -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

View File

@ -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<MethodCall> calls = <MethodCall>[];
/// The native event log, newest first.
final List<Map<String, Object?>> events = <Map<String, Object?>>[];
/// What `enforcementState` returns.
Map<String, Object?>? state;
/// What `escapeToday` returns.
bool escapeResult = true;
/// The clock stamping appended events.
DateTime Function() now = DateTime.now;
/// The method names received, in order.
List<String> 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<Object?> _handle(MethodCall call) async {
calls.add(call);
final args = call.arguments;
switch (call.method) {
case 'appendEvent':
if (args is Map) {
events.insert(0, <String, Object?>{
'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<String, Object?> 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',
}) => <String, Object?>{
'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,
};

View File

@ -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<bool> record() async {
calls++;
return true;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakeEnforcementChannel native;
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
native = FakeEnforcementChannel()..install();
});
Future<void> 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());
});
}

View File

@ -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, <String, Object?>{
'kind': 'ring_screen_opened',
'data': <String, Object?>{},
});
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<void> 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,
});
});
});
}

View File

@ -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<String?> read() async => text;
@override
Future<void> write(String value) async => text = value;
}
Future<AlarmSyncService> _open() =>
AlarmSyncService.open(persistence: _MemPersistence(), deviceId: 'me');
Map<String, Object?> _event(DateTime at, String kind) => <String, Object?>{
'at': isoWithOffset(at),
'kind': kind,
'data': <String, Object?>{},
};
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final now = DateTime(2026, 10, 1, 7, 20);
late FakeEnforcementChannel native;
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
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'),
<String, Object?>{'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 = <String>[];
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 = <String>[];
Future<void> 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);
});
});
}

View File

@ -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(<String, Object>{}));
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(<String>['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);
});
});
}

View File

@ -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<bool> record() async {
calls++;
if (fail) throw StateError('boom');
return true;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel(headlessChannelName);
late List<String> calls;
setUp(() {
calls = <String>[];
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());
});
}

View File

@ -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');
});
}

View File

@ -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(<String, Object>{});
native = FakeEnforcementChannel()..install();
});
Future<int> 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<TextButton>(
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<FilledButton>(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);
});
}

62
scripts/pc/ring_alsa.sh Executable file
View File

@ -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 <rise.wav> <loop.wav>
# ============================================================================
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") <rise.wav> <loop.wav>" >&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 "$@"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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"

View File

@ -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"
}

9
wake-alarm-audit.service Normal file
View File

@ -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/<today>.md: merged phone + PC timeline,
# expected-vs-observed checks, escapes and mismatches first.
ExecStart=/usr/bin/python -m wake_alarm._audit

12
wake-alarm-audit.timer Normal file
View File

@ -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

View File

@ -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

14
wake-alarm-fallback.timer Normal file
View File

@ -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

11
wake-alarm-rtc.service Normal file
View File

@ -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

18
wake-alarm-rtc.timer Normal file
View File

@ -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

183
wake_alarm/_audit.py Normal file
View File

@ -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/<date>.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())

148
wake_alarm/_audit_checks.py Normal file
View File

@ -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

View File

@ -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)}

View File

@ -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

135
wake_alarm/_audit_stick.py Normal file
View File

@ -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")

44
wake_alarm/_cli_modes.py Normal file
View File

@ -0,0 +1,44 @@
"""The ``--<act> | --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 ``--<act>``, ``--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()

View File

@ -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"

View File

@ -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
),
)

View File

@ -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 {},
)

48
wake_alarm/_escapes.py Normal file
View File

@ -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

83
wake_alarm/_events.py Normal file
View File

@ -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

179
wake_alarm/_fallback.py Normal file
View File

@ -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())

View File

@ -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

View File

@ -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}")

View File

@ -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)

View File

@ -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()

View File

@ -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())

196
wake_alarm/_ring_player.py Normal file
View File

@ -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

120
wake_alarm/_ring_wav.py Normal file
View File

@ -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())

167
wake_alarm/_rtc.py Normal file
View File

@ -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 <epoch>``. 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())

122
wake_alarm/_rtc_hw.py Normal file
View File

@ -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 <args>``; ``(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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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)

Some files were not shown because too many files have changed in this diff Show More