mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
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:
parent
19f23f35a6
commit
728e00cde7
27
AGENTS.md
27
AGENTS.md
@ -7,10 +7,14 @@ dismissal; this repo's Python half only reads the alarm time the phone
|
|||||||
published and starts the morning routine at that time. The time is an earned
|
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`**.
|
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
|
The PC's waking role was deleted on 2026-08-30: no alarm window, no smart
|
||||||
escalation, no smart plug, no curtains, no `rtcwake`, no `shutdown` wrapper,
|
plug, no curtains, no `shutdown` wrapper, no sleep hook. On 2026-10-01 exactly
|
||||||
no sleep hook: waking someone reliably cost more than it returned. It is in
|
two pieces came back, because the phone **cannot** ring while powered off: a
|
||||||
git history; do not reintroduce any of it without saying why the phone can't.
|
**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)
|
## The dismiss sequence (phone)
|
||||||
|
|
||||||
@ -183,22 +187,17 @@ diet_guard outage. Verify against `/usr/bin/python3 -c "import <dep>"`.
|
|||||||
|
|
||||||
## Uninstalling
|
## Uninstalling
|
||||||
|
|
||||||
`uninstall.sh`, never `rm -rf` on the repo. `/usr/local/bin/shutdown` is
|
`uninstall.sh`, never `rm -rf` on the repo: it also removes the root ring
|
||||||
root-owned and shadows `/usr/bin/shutdown`; deleting the repo leaves it in
|
unit, the logind power-key drop-in and an RTC alarm we armed, and retires the
|
||||||
place, and with the alarm cache gone it falls through to
|
old `/usr/local/bin/shutdown` wrapper wherever one survives. `install.sh` calls `uninstall.sh --retired-only` as its last step, so
|
||||||
`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` is load-bearing for installing**, not just for removing —
|
**`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
|
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
|
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.
|
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
|
**`/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
|
uninstall. Since 2026-10-01 it is live again: the RTC self-wake's `rtcwake`
|
||||||
dead code and deleted on 2026-09-27; retiring the entry is a separate,
|
and the root ring unit's `systemctl start|stop` lines live there.
|
||||||
deliberate decision, never a side effect of an uninstall.
|
|
||||||
|
|
||||||
## Operational gotchas
|
## Operational gotchas
|
||||||
|
|
||||||
|
|||||||
146
DOCS-wake-enforcement.md
Normal file
146
DOCS-wake-enforcement.md
Normal 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.
|
||||||
120
install.sh
120
install.sh
@ -3,20 +3,26 @@
|
|||||||
#
|
#
|
||||||
# Usage: bash install.sh
|
# Usage: bash install.sh
|
||||||
#
|
#
|
||||||
# The PC no longer wakes anyone -- the phone does. All this installs is the
|
# The phone is the alarm. The PC starts the morning routine at the time the
|
||||||
# per-minute trigger that starts morning-routine.service (and therefore the
|
# phone last synced, and -- wake enforcement v2, DOCS-wake-enforcement.md --
|
||||||
# workout lock) at the time the phone last synced.
|
# rings itself only when the phone stays silent, and wakes itself (RTC)
|
||||||
|
# before a Tue/Wed/Thu alarm.
|
||||||
#
|
#
|
||||||
# What it does:
|
# What it does:
|
||||||
# 1. Installs wake_alarm + dependencies for /usr/bin/python
|
# 1. Installs wake_alarm + dependencies for /usr/bin/python
|
||||||
# 2. Installs the systemd user units and enables the per-minute trigger timer
|
# 2. Installs the systemd user units and enables their timers (trigger,
|
||||||
# 3. Removes the retired waking machinery from earlier installs
|
# 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:
|
# Deliberately NOT done, and why:
|
||||||
# - no shutdown wrapper, no rtcwake, no sleep hook: the PC does not wake you
|
# - no shutdown wrapper and no sleep hook: the RTC alarm lives in CMOS and
|
||||||
# - the /etc/sudoers.d/wake-alarm entry is left ALONE. screen-locker's own
|
# survives however the PC goes down, so neither is needed
|
||||||
# _wake_alarm.py still shells out to `sudo rtcwake`, so removing it here
|
# - systemd-logind is never restarted (that kills the graphical session);
|
||||||
# would silently break a live feature in another repo.
|
# the drop-in is applied with a HUP reload
|
||||||
|
|
||||||
set -euo pipefail
|
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_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-session.service"
|
||||||
readonly SESSION_TIMER_SRC="$SCRIPT_DIR/wake-alarm-session.timer"
|
readonly SESSION_TIMER_SRC="$SCRIPT_DIR/wake-alarm-session.timer"
|
||||||
readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
|
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 "=== 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
|
# Editable, and from this clone: a later `git pull` here then updates the
|
||||||
# running production code with no reinstall. A non-editable snapshot taken
|
# running production code with no reinstall. A non-editable snapshot taken
|
||||||
# from a scratch directory silently stops tracking this repo.
|
# 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"
|
/usr/bin/python3 -c "import wake_alarm; import gatelock"
|
||||||
echo " wake_alarm and gatelock import under /usr/bin/python."
|
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"
|
mkdir -p "$SYSTEMD_USER_DIR"
|
||||||
cp "$TRIGGER_SERVICE_SRC" "$TRIGGER_TIMER_SRC" \
|
cp "$TRIGGER_SERVICE_SRC" "$TRIGGER_TIMER_SRC" \
|
||||||
"$SESSION_SERVICE_SRC" "$SESSION_TIMER_SRC" "$SYSTEMD_USER_DIR/"
|
"$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 daemon-reload
|
||||||
systemctl --user enable --now wake-alarm-trigger.timer
|
systemctl --user enable --now wake-alarm-trigger.timer
|
||||||
echo " Trigger timer enabled and started (ticks once a minute)."
|
echo " Trigger timer enabled and started (ticks once a minute)."
|
||||||
systemctl --user enable --now wake-alarm-session.timer
|
systemctl --user enable --now wake-alarm-session.timer
|
||||||
echo " Session timer enabled (07:00-11:00, every 2 min)."
|
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`
|
# Opportunistic, and deliberately NOT fatal. This script runs under `set -e`
|
||||||
# and the cleanup needs root, so on a machine without cached/passwordless sudo
|
# 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
|
# 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 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 "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 "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]"
|
||||||
|
|||||||
@ -84,6 +84,11 @@
|
|||||||
<receiver
|
<receiver
|
||||||
android:name=".WatchdogReceiver"
|
android:name=".WatchdogReceiver"
|
||||||
android:exported="false"/>
|
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
|
<receiver
|
||||||
android:name=".BootReceiver"
|
android:name=".BootReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
@ -143,9 +143,13 @@ object AlarmChannel {
|
|||||||
// means the ring was dismissed.
|
// means the ring was dismissed.
|
||||||
"markRingDismissed" -> {
|
"markRingDismissed" -> {
|
||||||
RingWatch.markDismissed(context)
|
RingWatch.markDismissed(context)
|
||||||
|
StickRunner.onDismissed(context)
|
||||||
|
EventLog.append(context, "dismissed")
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
else -> result.notImplemented()
|
else -> if (!EnforcementChannel.handleCall(context, call, result)) {
|
||||||
|
result.notImplemented()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ class AlarmReceiver : BroadcastReceiver() {
|
|||||||
// re-arm -- that is the wake alarm's business, not this one's.
|
// re-arm -- that is the wake alarm's business, not this one's.
|
||||||
if (intent.getStringExtra(AlarmScheduler.EXTRA_REASON) == AlarmScheduler.REASON_RERING) {
|
if (intent.getStringExtra(AlarmScheduler.EXTRA_REASON) == AlarmScheduler.REASON_RERING) {
|
||||||
Log.i(TAG, "session deadline missed: re-ringing")
|
Log.i(TAG, "session deadline missed: re-ringing")
|
||||||
|
EventLog.append(context, "alarm_fired", mapOf("reason" to "rering"))
|
||||||
AlarmService.start(context)
|
AlarmService.start(context)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -29,6 +30,7 @@ class AlarmReceiver : BroadcastReceiver() {
|
|||||||
// because the armed instant has passed) so the day after is normal.
|
// because the armed instant has passed) so the day after is normal.
|
||||||
if (FreeDays.isToday(context)) {
|
if (FreeDays.isToday(context)) {
|
||||||
Log.i(TAG, "free day: not ringing; re-armed for tomorrow")
|
Log.i(TAG, "free day: not ringing; re-armed for tomorrow")
|
||||||
|
EventLog.append(context, "alarm_fired", mapOf("reason" to "free_day"))
|
||||||
AlarmScheduler.rearmFromStorage(context)
|
AlarmScheduler.rearmFromStorage(context)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -37,6 +39,10 @@ class AlarmReceiver : BroadcastReceiver() {
|
|||||||
// already woken the CPU by the time this line runs.
|
// already woken the CPU by the time this line runs.
|
||||||
RingWatch.markFired(context)
|
RingWatch.markFired(context)
|
||||||
AlarmService.start(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 {
|
private companion object {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import android.app.PendingIntent
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import java.time.LocalTime
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -61,12 +62,16 @@ object AlarmScheduler {
|
|||||||
AlarmManager.AlarmClockInfo(at, showIntent(context)),
|
AlarmManager.AlarmClockInfo(at, showIntent(context)),
|
||||||
firePendingIntent(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. */
|
/** Cancels the armed alarm and forgets the stored time. */
|
||||||
fun cancel(context: Context) {
|
fun cancel(context: Context) {
|
||||||
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
manager.cancel(firePendingIntent(context))
|
manager.cancel(firePendingIntent(context))
|
||||||
|
StickAlarm.cancel(context)
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
.remove(KEY_HOUR).remove(KEY_MINUTE).apply()
|
.remove(KEY_HOUR).remove(KEY_MINUTE).apply()
|
||||||
}
|
}
|
||||||
@ -86,6 +91,15 @@ object AlarmScheduler {
|
|||||||
return true
|
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].
|
* Arms the morning session's re-ring for the absolute instant [atMillis].
|
||||||
*
|
*
|
||||||
|
|||||||
@ -54,10 +54,9 @@ object AlarmWatchdog {
|
|||||||
fun verify(context: Context) {
|
fun verify(context: Context) {
|
||||||
val rearmed = AlarmScheduler.rearmFromStorage(context)
|
val rearmed = AlarmScheduler.rearmFromStorage(context)
|
||||||
Log.i(TAG, "daily check: rearmed=$rearmed")
|
Log.i(TAG, "daily check: rearmed=$rearmed")
|
||||||
// Fallback net for an abandoned ring on a phone that never rebooted
|
// Third net behind the exact check and boot: within a day, whatever
|
||||||
// (BootReceiver already covers the poweroff case): within a day,
|
// dropped the other two, today's miss is still caught here.
|
||||||
// whatever the reason, WorkdayStick catches it here too.
|
StickRunner.run(context, "watchdog")
|
||||||
WorkdayStick.checkAndSignal(context)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pendingIntent(context: Context): PendingIntent =
|
private fun pendingIntent(context: Context): PendingIntent =
|
||||||
|
|||||||
@ -20,13 +20,15 @@ class BootReceiver : BroadcastReceiver() {
|
|||||||
// a reboot, either -- see restoreReRing for the launch-time replay.
|
// a reboot, either -- see restoreReRing for the launch-time replay.
|
||||||
val rering = AlarmScheduler.restoreReRing(context)
|
val rering = AlarmScheduler.restoreReRing(context)
|
||||||
Log.i(TAG, "boot: rearmed=$rearmed rering=$rering")
|
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
|
// setInexactRepeating does not survive a reboot, so the daily check
|
||||||
// has to be re-established here or it silently stops after the first
|
// has to be re-established here or it silently stops after the first
|
||||||
// restart — exactly when a watchdog is most needed.
|
// restart — exactly when a watchdog is most needed.
|
||||||
AlarmWatchdog.schedule(context)
|
AlarmWatchdog.schedule(context)
|
||||||
// The moment a phone powered off mid-ring comes back is exactly when
|
// A phone switched off before the alarm and back on after A + 15 has
|
||||||
// an abandoned ring should be caught -- see WorkdayStick.
|
// missed: this is the path that closes that hole (rearmFromStorage
|
||||||
WorkdayStick.checkAndSignal(context)
|
// above has already armed the exact check for a phone back earlier).
|
||||||
|
StickRunner.run(context, "boot")
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -31,10 +31,17 @@ object FreeDays {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Whether today (local calendar day) is in the cached set. */
|
/** 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)
|
val dates = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getStringSet(KEY_DATES, emptySet()) ?: emptySet()
|
.getStringSet(KEY_DATES, emptySet()) ?: emptySet()
|
||||||
dates.contains(LocalDate.now().toString())
|
dates.contains(day.toString())
|
||||||
} catch (e: RuntimeException) {
|
} catch (e: RuntimeException) {
|
||||||
Log.w(TAG, "free-day cache unreadable; ringing as normal", e)
|
Log.w(TAG, "free-day cache unreadable; ringing as normal", e)
|
||||||
false
|
false
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ package com.kuhy.wake_alarm_sync
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
|
import java.time.LocalDate
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@ -21,14 +22,16 @@ import java.util.Locale
|
|||||||
* the initial dismiss gate actually completes (`session_handoff.dart`'s
|
* the initial dismiss gate actually completes (`session_handoff.dart`'s
|
||||||
* `afterDismiss`).
|
* `afterDismiss`).
|
||||||
*
|
*
|
||||||
* [abandonedRingDate] is non-null exactly when a ring fired and nothing
|
* The stick ([StickCheck]) reads only [dismissedOn]. It used to ask "did a
|
||||||
* since has dismissed it -- checked from [BootReceiver] and the daily
|
* ring fire and go undismissed", which needs the *fired* date -- and a phone
|
||||||
* [WatchdogReceiver] tick, both of which run without a Flutter engine.
|
* switched off before the alarm never fires, so that escaped everything.
|
||||||
|
* The fired date stays as evidence for whoever debugs a morning.
|
||||||
*/
|
*/
|
||||||
object RingWatch {
|
object RingWatch {
|
||||||
private const val PREFS = "wake_alarm_ring_watch"
|
private const val PREFS = "wake_alarm_ring_watch"
|
||||||
private const val KEY_FIRED_DATE = "fired_date"
|
private const val KEY_FIRED_DATE = "fired_date"
|
||||||
private const val KEY_DISMISSED_DATE = "dismissed_date"
|
private const val KEY_DISMISSED_DATE = "dismissed_date"
|
||||||
|
private const val KEY_DISMISSED_DATES = "dismissed_dates"
|
||||||
|
|
||||||
fun markFired(context: Context) {
|
fun markFired(context: Context) {
|
||||||
prefs(context).edit().putString(KEY_FIRED_DATE, today()).apply()
|
prefs(context).edit().putString(KEY_FIRED_DATE, today()).apply()
|
||||||
@ -36,14 +39,17 @@ object RingWatch {
|
|||||||
|
|
||||||
fun markDismissed(context: Context) {
|
fun markDismissed(context: Context) {
|
||||||
prefs(context).edit().putString(KEY_DISMISSED_DATE, today()).apply()
|
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? {
|
* Whether the dismiss gate completed on [day]; read by [StickRunner],
|
||||||
val fired = prefs(context).getString(KEY_FIRED_DATE, null) ?: return null
|
* which judges past days too. The single [KEY_DISMISSED_DATE] predates
|
||||||
val dismissed = prefs(context).getString(KEY_DISMISSED_DATE, null)
|
* the history and still answers for its one day.
|
||||||
return if (fired == dismissed) null else fired
|
*/
|
||||||
}
|
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) =
|
private fun prefs(context: Context) =
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -3,13 +3,13 @@ package com.kuhy.wake_alarm_sync
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.text.SimpleDateFormat
|
import java.time.DayOfWeek
|
||||||
import java.util.Calendar
|
import java.time.LocalDate
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signals `focus-owner` to lock down distracting apps when a
|
* Signals `focus-owner` (`~/src/phone-focus-mode`): lock distracting apps
|
||||||
* Tuesday/Wednesday/Thursday ring was abandoned -- see [RingWatch].
|
* 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
|
* 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
|
* PC side for the debt-stacking half of this same stick: both go easy on
|
||||||
@ -17,32 +17,35 @@ import java.util.Locale
|
|||||||
* nothing.
|
* nothing.
|
||||||
*/
|
*/
|
||||||
object WorkdayStick {
|
object WorkdayStick {
|
||||||
/** [Calendar.DAY_OF_WEEK] values for Tuesday/Wednesday/Thursday. */
|
/** Tuesday/Wednesday/Thursday; mirrored by `test_workday_mirrors.py`. */
|
||||||
private val STICK_DAYS =
|
val STICK_DAYS =
|
||||||
setOf(Calendar.TUESDAY, Calendar.WEDNESDAY, Calendar.THURSDAY)
|
setOf(DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY)
|
||||||
|
|
||||||
private const val ACTION_LOCKDOWN = "com.kuhy.focus_owner.action.LOCKDOWN_UNTIL_EOD"
|
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 PERMISSION_SIGNAL = "com.kuhy.focus_owner.permission.SIGNAL_LOCKDOWN"
|
||||||
private const val FOCUS_OWNER_PACKAGE = "com.kuhy.focus_owner"
|
private const val FOCUS_OWNER_PACKAGE = "com.kuhy.focus_owner"
|
||||||
|
|
||||||
private const val TAG = "WakeAlarmWorkdayStick"
|
private const val TAG = "WakeAlarmWorkdayStick"
|
||||||
|
|
||||||
/** Checks for an abandoned stick-day ring and signals focus-owner. */
|
/** Locks down through [until], inclusive (`yyyy-MM-dd` on the wire). */
|
||||||
fun checkAndSignal(context: Context) {
|
fun sendLockdown(context: Context, until: LocalDate) {
|
||||||
val abandoned = RingWatch.abandonedRingDate(context) ?: return
|
Log.i(TAG, "signalling focus-owner lockdown through $until")
|
||||||
if (!isStickDay(abandoned)) return
|
|
||||||
Log.i(TAG, "abandoned ring on $abandoned: signalling focus-owner lockdown")
|
|
||||||
context.sendBroadcast(
|
context.sendBroadcast(
|
||||||
Intent(ACTION_LOCKDOWN).setPackage(FOCUS_OWNER_PACKAGE),
|
Intent(ACTION_LOCKDOWN)
|
||||||
|
.setPackage(FOCUS_OWNER_PACKAGE)
|
||||||
|
.putExtra(EXTRA_UNTIL, until.toString()),
|
||||||
PERMISSION_SIGNAL,
|
PERMISSION_SIGNAL,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isStickDay(isoDate: String): Boolean {
|
/** Clears any lockdown now (the escape hatch). */
|
||||||
val format = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
fun sendLift(context: Context) {
|
||||||
val parsed = runCatching { format.parse(isoDate) }.getOrNull() ?: return false
|
Log.i(TAG, "signalling focus-owner lift")
|
||||||
val calendar = Calendar.getInstance()
|
context.sendBroadcast(
|
||||||
calendar.time = parsed
|
Intent(ACTION_LIFT).setPackage(FOCUS_OWNER_PACKAGE),
|
||||||
return calendar.get(Calendar.DAY_OF_WEEK) in STICK_DAYS
|
PERMISSION_SIGNAL,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/screens/session_screen.dart';
|
||||||
import 'package:wake_alarm_sync/services/alarm_policy.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/alarm_scheduler.dart';
|
||||||
|
import 'package:wake_alarm_sync/services/headless_ring_start.dart';
|
||||||
import 'package:wake_alarm_sync/ui/theme.dart';
|
import 'package:wake_alarm_sync/ui/theme.dart';
|
||||||
|
|
||||||
/// Route the Kotlin `AlarmActivity` hands to the engine as its initial
|
/// Route the Kotlin `AlarmActivity` hands to the engine as its initial
|
||||||
@ -29,6 +30,14 @@ Future<void> main() async {
|
|||||||
runApp(const WakeAlarmSyncApp());
|
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.
|
/// Root application widget.
|
||||||
class WakeAlarmSyncApp extends StatelessWidget {
|
class WakeAlarmSyncApp extends StatelessWidget {
|
||||||
/// Creates a [WakeAlarmSyncApp]; [newScheduler] is the probes' seam.
|
/// Creates a [WakeAlarmSyncApp]; [newScheduler] is the probes' seam.
|
||||||
|
|||||||
85
phone_app/lib/screens/escape_dialog.dart
Normal file
85
phone_app/lib/screens/escape_dialog.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:wake_alarm_sync/screens/home_history_card.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/home_presence_line.dart';
|
||||||
|
import 'package:wake_alarm_sync/screens/stakes_card.dart';
|
||||||
import 'package:wake_alarm_sync/ui/theme.dart';
|
import 'package:wake_alarm_sync/ui/theme.dart';
|
||||||
|
|
||||||
/// The home screen's body: the armed time, the earlier-only control and the
|
/// 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.configured,
|
||||||
required this.onPickEarlier,
|
required this.onPickEarlier,
|
||||||
this.syncEpoch = 0,
|
this.syncEpoch = 0,
|
||||||
|
this.onEscaped,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -49,6 +51,9 @@ class HomeBody extends StatelessWidget {
|
|||||||
/// until the next cold start.
|
/// until the next cold start.
|
||||||
final int syncEpoch;
|
final int syncEpoch;
|
||||||
|
|
||||||
|
/// Called after the escape hatch was used, so the screen publishes it.
|
||||||
|
final VoidCallback? onEscaped;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
@ -109,6 +114,14 @@ class HomeBody extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
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
|
// Not `ValueKey<int>(syncEpoch)`: the presence line above
|
||||||
// already carries that key in this Column, and two children
|
// already carries that key in this Column, and two children
|
||||||
// with one key mis-reconcile silently in release -- the old
|
// with one key mis-reconcile silently in release -- the old
|
||||||
|
|||||||
@ -236,6 +236,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
configured: _configured,
|
configured: _configured,
|
||||||
syncEpoch: _syncEpoch,
|
syncEpoch: _syncEpoch,
|
||||||
onPickEarlier: () => unawaited(_pickEarlier()),
|
onPickEarlier: () => unawaited(_pickEarlier()),
|
||||||
|
onEscaped: () => unawaited(_syncToPc()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:wake_alarm_sync/screens/ring_challenge.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_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/challenge.dart';
|
||||||
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
|
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
|
||||||
|
|
||||||
@ -50,7 +51,9 @@ class RingBody extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text('Wake up', style: theme.textTheme.headlineMedium),
|
Text('Wake up', style: theme.textTheme.headlineMedium),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 16),
|
||||||
|
const RingStakesLine(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
if (inChallenges)
|
if (inChallenges)
|
||||||
RingChallenge(
|
RingChallenge(
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:wake_alarm_sync/services/challenge.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.
|
/// The question half of the ring screen, shown once the walk is proven.
|
||||||
class RingChallenge extends StatelessWidget {
|
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}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -3,12 +3,14 @@ import 'dart:developer' as developer;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:wake_alarm_sync/screens/ring_body.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_policy.dart';
|
||||||
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
||||||
import 'package:wake_alarm_sync/services/challenge.dart';
|
import 'package:wake_alarm_sync/services/challenge.dart';
|
||||||
import 'package:wake_alarm_sync/services/dismiss_gate.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/home_presence.dart';
|
||||||
import 'package:wake_alarm_sync/services/ramp_progress.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/ring_startup.dart';
|
||||||
import 'package:wake_alarm_sync/services/room_proof.dart';
|
import 'package:wake_alarm_sync/services/room_proof.dart';
|
||||||
import 'package:wake_alarm_sync/services/session_handoff.dart';
|
import 'package:wake_alarm_sync/services/session_handoff.dart';
|
||||||
@ -32,6 +34,7 @@ class RingScreen extends StatefulWidget {
|
|||||||
this.clock,
|
this.clock,
|
||||||
this.presence,
|
this.presence,
|
||||||
this.handoff,
|
this.handoff,
|
||||||
|
this.ringStart,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Platform bridge; defaults to the real MethodChannel.
|
/// 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.
|
/// Starts or resumes the morning session; defaults to the real handoff.
|
||||||
final SessionHandoff? 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`.
|
/// Time since the ring started; defaults to a real `Stopwatch`.
|
||||||
///
|
///
|
||||||
/// Injectable because `Stopwatch` reads the wall clock, which
|
/// Injectable because `Stopwatch` reads the wall clock, which
|
||||||
@ -80,6 +86,9 @@ class _RingScreenState extends State<RingScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
unawaited(_scheduler.beginStepCount());
|
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());
|
unawaited(_loadRoomProof());
|
||||||
// The escape hatch. Set up before anything can fail, so a crash in the
|
// The escape hatch. Set up before anything can fail, so a crash in the
|
||||||
// settings load cannot leave an alarm with no way out.
|
// 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();
|
if (_gate.stage == DismissStage.challenges) _logChallenge();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Logs the current challenge so a hardware test can be scripted.
|
void _logChallenge() => logChallenge(_challenge, _gate);
|
||||||
///
|
|
||||||
/// 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}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|||||||
63
phone_app/lib/screens/ring_stakes_line.dart
Normal file
63
phone_app/lib/screens/ring_stakes_line.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
162
phone_app/lib/screens/stakes_card.dart
Normal file
162
phone_app/lib/screens/stakes_card.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
phone_app/lib/services/enforcement_bridge.dart
Normal file
49
phone_app/lib/services/enforcement_bridge.dart
Normal 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;
|
||||||
|
}
|
||||||
103
phone_app/lib/services/enforcement_state.dart
Normal file
103
phone_app/lib/services/enforcement_state.dart
Normal 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);
|
||||||
|
}
|
||||||
109
phone_app/lib/services/enforcement_sync.dart
Normal file
109
phone_app/lib/services/enforcement_sync.dart
Normal 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];
|
||||||
|
}
|
||||||
33
phone_app/lib/services/escape_hatch.dart
Normal file
33
phone_app/lib/services/escape_hatch.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
phone_app/lib/services/escape_log.dart
Normal file
54
phone_app/lib/services/escape_log.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
phone_app/lib/services/headless_ring_start.dart
Normal file
36
phone_app/lib/services/headless_ring_start.dart
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
phone_app/lib/services/iso_time.dart
Normal file
25
phone_app/lib/services/iso_time.dart
Normal 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');
|
||||||
83
phone_app/lib/services/ring_start_recorder.dart
Normal file
83
phone_app/lib/services/ring_start_recorder.dart
Normal 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');
|
||||||
|
}
|
||||||
68
phone_app/lib/services/ring_started_store.dart
Normal file
68
phone_app/lib/services/ring_started_store.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,6 +19,7 @@ library;
|
|||||||
|
|
||||||
import 'package:crdt_sync/crdt_sync.dart';
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
import 'package:wake_alarm_sync/services/alarm_sync_service.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/home_location_settings.dart';
|
||||||
import 'package:wake_alarm_sync/services/ramp_progress.dart';
|
import 'package:wake_alarm_sync/services/ramp_progress.dart';
|
||||||
import 'package:wake_alarm_sync/services/room_proof_settings.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 _stageEarned(service);
|
||||||
await stageSessionData(service);
|
await stageSessionData(service);
|
||||||
|
await stageEnforcementData(service);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the union of the local and logged earned dates when it is new.
|
/// Pushes the union of the local and logged earned dates when it is new.
|
||||||
|
|||||||
49
phone_app/lib/services/stakes.dart
Normal file
49
phone_app/lib/services/stakes.dart
Normal 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);
|
||||||
166
phone_app/lib/services/today_stakes.dart
Normal file
166
phone_app/lib/services/today_stakes.dart
Normal 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]}';
|
||||||
@ -110,10 +110,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: cupertino_icons
|
name: cupertino_icons
|
||||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
sha256: b7cc2390aac296c083bea762859413eb1cf5b630c70e0c7e4d831018f762a8b2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.9"
|
version: "2.0.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -14,7 +14,7 @@ dependencies:
|
|||||||
url: https://github.com/kuhyx/utils
|
url: https://github.com/kuhyx/utils
|
||||||
ref: crdt_sync_dart-v0.11.1
|
ref: crdt_sync_dart-v0.11.1
|
||||||
path: crdt_sync_dart
|
path: crdt_sync_dart
|
||||||
cupertino_icons: 1.0.9
|
cupertino_icons: 2.0.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_secure_storage: 11.2.0
|
flutter_secure_storage: 11.2.0
|
||||||
|
|||||||
94
phone_app/test/fake_enforcement_channel.dart
Normal file
94
phone_app/test/fake_enforcement_channel.dart
Normal 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,
|
||||||
|
};
|
||||||
81
phone_app/test/ring_stakes_test.dart
Normal file
81
phone_app/test/ring_stakes_test.dart
Normal 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());
|
||||||
|
});
|
||||||
|
}
|
||||||
129
phone_app/test/services/enforcement_bridge_test.dart
Normal file
129
phone_app/test/services/enforcement_bridge_test.dart
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
220
phone_app/test/services/enforcement_sync_test.dart
Normal file
220
phone_app/test/services/enforcement_sync_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
118
phone_app/test/services/escape_records_test.dart
Normal file
118
phone_app/test/services/escape_records_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
60
phone_app/test/services/headless_ring_start_test.dart
Normal file
60
phone_app/test/services/headless_ring_start_test.dart
Normal 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());
|
||||||
|
});
|
||||||
|
}
|
||||||
191
phone_app/test/services/today_stakes_test.dart
Normal file
191
phone_app/test/services/today_stakes_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
203
phone_app/test/stakes_card_test.dart
Normal file
203
phone_app/test/stakes_card_test.dart
Normal 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
62
scripts/pc/ring_alsa.sh
Executable 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 "$@"
|
||||||
6
scripts/pc/wake-alarm-powerkey.conf
Normal file
6
scripts/pc/wake-alarm-powerkey.conf
Normal 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
|
||||||
14
scripts/pc/wake-alarm-ring-alsa.service
Normal file
14
scripts/pc/wake-alarm-ring-alsa.service
Normal 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
|
||||||
@ -16,6 +16,8 @@ set -uo pipefail
|
|||||||
|
|
||||||
readonly PY=/usr/bin/python3
|
readonly PY=/usr/bin/python3
|
||||||
FAILURES=0
|
FAILURES=0
|
||||||
|
# shellcheck source=guest_cases_v2.sh source-path=SCRIPTDIR
|
||||||
|
source "$(dirname "$(readlink -f "$0")")/guest_cases_v2.sh"
|
||||||
|
|
||||||
pass() {
|
pass() {
|
||||||
echo "PASS $1"
|
echo "PASS $1"
|
||||||
@ -86,6 +88,7 @@ main() {
|
|||||||
case_units
|
case_units
|
||||||
case_trigger_dry_run
|
case_trigger_dry_run
|
||||||
case_session_refresh
|
case_session_refresh
|
||||||
|
run_v2_cases
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "usage: $0 [all]" >&2
|
echo "usage: $0 [all]" >&2
|
||||||
|
|||||||
130
scripts/vmbox/guest_cases_v2.sh
Normal file
130
scripts/vmbox/guest_cases_v2.sh
Normal 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
|
||||||
|
}
|
||||||
@ -34,7 +34,7 @@ readonly SCRIPT_NAME
|
|||||||
# dies with "libtk8.6.so: cannot open shared object file" without it. Measured
|
# 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
|
# in the guest, not guessed -- dropping this package is what broke the harness
|
||||||
# when the alarm window was deleted.
|
# 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().
|
# Printed by guest_cases.sh only when every case passed. See run_cases().
|
||||||
readonly GUEST_OK_SENTINEL="ALL-CASES-PASSED"
|
readonly GUEST_OK_SENTINEL="ALL-CASES-PASSED"
|
||||||
|
|||||||
41
uninstall.sh
41
uninstall.sh
@ -21,6 +21,16 @@ set -euo pipefail
|
|||||||
readonly SLEEP_HOOK_DST="/usr/lib/systemd/system-sleep/wake-alarm.sh"
|
readonly SLEEP_HOOK_DST="/usr/lib/systemd/system-sleep/wake-alarm.sh"
|
||||||
readonly SHUTDOWN_WRAPPER_DST="/usr/local/bin/shutdown"
|
readonly SHUTDOWN_WRAPPER_DST="/usr/local/bin/shutdown"
|
||||||
readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
|
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
|
RETIRED_ONLY=0
|
||||||
|
|
||||||
@ -82,12 +92,40 @@ remove_trigger() {
|
|||||||
"$SYSTEMD_USER_DIR/wake-alarm-session.timer"
|
"$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() {
|
main() {
|
||||||
echo "=== Removing the PC's waking machinery ==="
|
echo "=== Removing the PC's waking machinery ==="
|
||||||
remove_retired
|
remove_retired
|
||||||
|
|
||||||
if [[ $RETIRED_ONLY -eq 0 ]]; then
|
if [[ $RETIRED_ONLY -eq 0 ]]; then
|
||||||
remove_trigger
|
remove_trigger
|
||||||
|
remove_enforcement
|
||||||
echo " Note: the workout lock no longer starts at the phone's alarm time."
|
echo " Note: the workout lock no longer starts at the phone's alarm time."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -95,7 +133,8 @@ main() {
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Left in place on purpose:"
|
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
|
||||||
echo "Verify: command -v shutdown # expect /usr/bin/shutdown"
|
echo "Verify: command -v shutdown # expect /usr/bin/shutdown"
|
||||||
}
|
}
|
||||||
|
|||||||
9
wake-alarm-audit.service
Normal file
9
wake-alarm-audit.service
Normal 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
12
wake-alarm-audit.timer
Normal 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
|
||||||
16
wake-alarm-fallback.service
Normal file
16
wake-alarm-fallback.service
Normal 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
14
wake-alarm-fallback.timer
Normal 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
11
wake-alarm-rtc.service
Normal 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
18
wake-alarm-rtc.timer
Normal 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
183
wake_alarm/_audit.py
Normal 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
148
wake_alarm/_audit_checks.py
Normal 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
|
||||||
137
wake_alarm/_audit_evidence.py
Normal file
137
wake_alarm/_audit_evidence.py
Normal 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)}
|
||||||
184
wake_alarm/_audit_sources.py
Normal file
184
wake_alarm/_audit_sources.py
Normal 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
135
wake_alarm/_audit_stick.py
Normal 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
44
wake_alarm/_cli_modes.py
Normal 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()
|
||||||
@ -7,7 +7,7 @@ say, and has the morning routine already started today.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Days the wake alarm is active (Python weekday(): Mon=0 ... Sun=6)
|
# 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
|
# Workday stick (a Tue/Wed/Thu miss -> tomorrow loses its leniency). The days
|
||||||
# themselves are freedays.WORKDAYS, the one set every gate app reads.
|
# themselves are freedays.WORKDAYS, the one set every gate app reads.
|
||||||
WORKDAY_PENALTY_FILE: Path = LOG_STATE_DIR / "workday_penalty.json"
|
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"
|
||||||
|
|||||||
165
wake_alarm/_enforcement_payload.py
Normal file
165
wake_alarm/_enforcement_payload.py
Normal 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
|
||||||
|
),
|
||||||
|
)
|
||||||
127
wake_alarm/_enforcement_records.py
Normal file
127
wake_alarm/_enforcement_records.py
Normal 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
48
wake_alarm/_escapes.py
Normal 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
83
wake_alarm/_events.py
Normal 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
179
wake_alarm/_fallback.py
Normal 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())
|
||||||
88
wake_alarm/_fallback_latch.py
Normal file
88
wake_alarm/_fallback_latch.py
Normal 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
|
||||||
100
wake_alarm/_fallback_observe.py
Normal file
100
wake_alarm/_fallback_observe.py
Normal 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}")
|
||||||
159
wake_alarm/_fallback_policy.py
Normal file
159
wake_alarm/_fallback_policy.py
Normal 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)
|
||||||
108
wake_alarm/_fallback_ring.py
Normal file
108
wake_alarm/_fallback_ring.py
Normal 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()
|
||||||
51
wake_alarm/_pc_wake_mode.py
Normal file
51
wake_alarm/_pc_wake_mode.py
Normal 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
196
wake_alarm/_ring_player.py
Normal 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
120
wake_alarm/_ring_wav.py
Normal 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
167
wake_alarm/_rtc.py
Normal 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
122
wake_alarm/_rtc_hw.py
Normal 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"
|
||||||
@ -35,6 +35,8 @@ import sys
|
|||||||
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
|
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._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._logging_setup import configure_logging
|
||||||
from wake_alarm._session_payload import newest_session
|
from wake_alarm._session_payload import newest_session
|
||||||
from wake_alarm._session_policy import to_entry
|
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):
|
if not write_entry(entry):
|
||||||
return None
|
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
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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``:
|
to tomorrow. This file's only field a consumer checks is ``penalty_date``:
|
||||||
today == penalty_date means "no leniency today," the same shape as
|
today == penalty_date means "no leniency today," the same shape as
|
||||||
``morning_session.json``'s own ``exempt_until`` predicate.
|
``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
|
from __future__ import annotations
|
||||||
@ -25,6 +28,7 @@ import freedays
|
|||||||
from gatelock.log_integrity import compute_entry_hmac
|
from gatelock.log_integrity import compute_entry_hmac
|
||||||
|
|
||||||
from wake_alarm._constants import WORKDAY_PENALTY_FILE
|
from wake_alarm._constants import WORKDAY_PENALTY_FILE
|
||||||
|
from wake_alarm._events import emit
|
||||||
from wake_alarm._session_policy import NONE_OUTCOME
|
from wake_alarm._session_policy import NONE_OUTCOME
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@ -81,7 +85,40 @@ def write_penalty(entry: dict[str, object]) -> bool:
|
|||||||
return True
|
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.
|
"""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
|
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
|
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
|
direction (a corrupt read is "not free"), which is exactly what is
|
||||||
wanted here too: an unreadable pool means no penalty, never a spurious
|
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
|
return False
|
||||||
if not is_workday_miss(entry, now):
|
if not is_workday_miss(entry, now):
|
||||||
return False
|
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
|
||||||
|
|||||||
@ -19,10 +19,16 @@ an inhibitor:
|
|||||||
``~/.local/state/wake_alarm/events.jsonl``. Unredirected, every suite run
|
``~/.local/state/wake_alarm/events.jsonl``. Unredirected, every suite run
|
||||||
appended fixture warnings ("Synced alarm out of range: 24:0") to the log a
|
appended fixture warnings ("Synced alarm out of range: 24:0") to the log a
|
||||||
failed wake-up is reconstructed from.
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import ExitStack
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@ -34,12 +40,20 @@ if TYPE_CHECKING:
|
|||||||
from pathlib import Path
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def _hermetic(tmp_path: Path) -> Generator[None]:
|
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 = tmp_path / "hmac.key"
|
||||||
key_file.write_bytes(b"0" * 32)
|
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("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key_file),
|
||||||
patch("wake_alarm._state.WAKE_STATE_FILE", tmp_path / "wake_state.json"),
|
patch("wake_alarm._state.WAKE_STATE_FILE", tmp_path / "wake_state.json"),
|
||||||
# _logging_setup imported these by value, so patch its names, not
|
# _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",
|
"wake_alarm._workday_penalty.WORKDAY_PENALTY_FILE",
|
||||||
tmp_path / "workday_penalty.json",
|
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
|
# The shared free-day pool lives under ~/.local/share, outside every
|
||||||
# path above. Without this, a real free day on the developer's
|
# path above. Without this, a real free day on the developer's
|
||||||
# machine would silence the alarm in every trigger test here.
|
# 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",
|
"freedays._api.resolve_paths",
|
||||||
lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"),
|
lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"),
|
||||||
),
|
),
|
||||||
):
|
]
|
||||||
|
with ExitStack() as stack:
|
||||||
|
for redirect in patches:
|
||||||
|
stack.enter_context(redirect)
|
||||||
yield
|
yield
|
||||||
|
|||||||
190
wake_alarm/tests/test_audit.py
Normal file
190
wake_alarm/tests/test_audit.py
Normal 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
|
||||||
232
wake_alarm/tests/test_audit_checks.py
Normal file
232
wake_alarm/tests/test_audit_checks.py
Normal 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"
|
||||||
140
wake_alarm/tests/test_audit_sources.py
Normal file
140
wake_alarm/tests/test_audit_sources.py
Normal 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
|
||||||
87
wake_alarm/tests/test_enforcement_payload.py
Normal file
87
wake_alarm/tests/test_enforcement_payload.py
Normal 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))
|
||||||
78
wake_alarm/tests/test_enforcement_records.py
Normal file
78
wake_alarm/tests/test_enforcement_records.py
Normal 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
|
||||||
125
wake_alarm/tests/test_escapes.py
Normal file
125
wake_alarm/tests/test_escapes.py
Normal 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
|
||||||
70
wake_alarm/tests/test_events.py
Normal file
70
wake_alarm/tests/test_events.py
Normal 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
|
||||||
175
wake_alarm/tests/test_fallback.py
Normal file
175
wake_alarm/tests/test_fallback.py
Normal 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"
|
||||||
73
wake_alarm/tests/test_fallback_latch.py
Normal file
73
wake_alarm/tests/test_fallback_latch.py
Normal 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
Loading…
Reference in New Issue
Block a user